From 6ab5016575e050225af98786eb367f26e78654f2 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:26:47 +1000 Subject: [PATCH 1/5] fix(binding): write only the latest waiting value to the view - A value waiting for the view's owning thread is replaced by a newer one, so a burst of changes becomes one write. - Bind and BindTwoWay settle on the latest value after two changes from another thread, instead of bouncing values between the view model and the view. - The README and CLAUDE.md describe the latest-value wait, and that Unsafe bindings route writes only through registered invokers. --- CLAUDE.md | 16 +- README.md | 21 +- .../Observables/ViewThreadObservable.cs | 203 ++++++++++-------- .../ViewWriteSchedulingRuntimeTests.cs | 114 ++++++++++ .../Observables/ViewThreadObservableTests.cs | 50 ++++- 5 files changed, 303 insertions(+), 101 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9bc8b794..469ee712 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -474,12 +474,16 @@ a view's unit test. The MAUI invoker catches it. It treats the object as having 1. It uses the first registered invoker that claims the target. A generated binding also passes a fallback: an invoker to use when no registered one claims the target. With no invoker at all, the source comes back unchanged. -2. A notification runs inline when nothing is queued and `CheckAccess` passes. -3. Any other notification is queued. One callback empties the queue. It runs on `BindingSchedulers.MainThread` - when that is set, and through `Post` otherwise. +2. A notification runs inline when nothing is waiting and `CheckAccess` passes. +3. Any other notification waits. One drain delivers what waits. It runs on `BindingSchedulers.MainThread` when that + is set, and through `Post` otherwise. -A notification that arrives behind queued ones waits its turn, even on the owning thread. The view sees values in -the order the source produced them. +Only the latest value waits. A newer value replaces it, even one raised on the owning thread while a drain runs. +Completion and errors wait beside the value and are delivered after it. + +Keeping every value breaks two-way bindings. Writing a view raises the view's own change at once. If a newer value +is still waiting, that echo writes the older value back to the view model. The write raises another change, and +the two sides bounce forever. `ViewWriteSchedulingRuntimeTests` covers this for `Bind` and `BindTwoWay`. `MainThread` only carries writes from another thread to a claimed object. It never sees an on-thread write. It never sees a write to an unclaimed object. @@ -497,6 +501,8 @@ never sees a write to an unclaimed object. The generator declares a class when its platform type resolves in the compilation. It does not look at call sites. A call site can only name an invoker for a type that resolves. So every reference has a declaration. +An `Unsafe` binding only has the registered invokers. It routes writes only when the platform module is registered. + There is no WinUI invoker. No runtime package registers one. A generated one would route writes that the `Unsafe` twin does not. diff --git a/README.md b/README.md index 1a274168..8bf1ef31 100644 --- a/README.md +++ b/README.md @@ -616,10 +616,11 @@ right one. The binding asks on every write. -- A write on the owning thread runs straight away. Set a property on the UI thread, and the control has the new - value on the next line. -- A write from another thread waits for the owning thread. -- Writes keep their order. +- A write on the owning thread runs straight away when no earlier write is waiting. Set a property on the UI + thread, and the control has the new value on the next line. +- A write from another thread waits for the owning thread. A write on the owning thread also waits while an + earlier write is waiting. +- Only the latest value waits. A newer change replaces the waiting one, so a burst of changes becomes one write. Some objects have no owning thread. The binding writes to them straight away. @@ -629,7 +630,7 @@ Some objects have no owning thread. The binding writes to them straight away. - Any object that is not a WPF, WinForms or MAUI object, such as a plain view model. Every binding API does this: `BindOneWay`, `BindTwoWay`, `OneWayBind`, `Bind`, `BindTo`, and `BindCommand` when -it binds a new command to the control. Each `Unsafe` twin does the same. +it binds a new command to the control. Each `Unsafe` twin does the same through the registered invokers. ### Invokers @@ -640,6 +641,9 @@ An invoker you register is asked first. A generated binding knows its target's type when it compiles. For a WPF, WinForms or MAUI target, it carries that platform's invoker. So it routes writes even when the platform module is not registered. +An `Unsafe` binding only finds its target's type while the app runs. It uses the registered invokers alone. Register +the platform module when you use `Unsafe` bindings. + ### Choosing the thread yourself > [!TIP] @@ -751,7 +755,12 @@ message loop later. Where ReactiveUI does move a write, the order is the same. A write on the owning thread runs straight away. A write from another thread goes through the main-thread scheduler. Set `BindingSchedulers.MainThread` to -ReactiveUI's main-thread scheduler to match it exactly. +ReactiveUI's main-thread scheduler to use the same scheduler. + +A burst of changes from another thread is handled differently. ReactiveUI's one-way bindings and `BindTo` write +every value, on the thread that raised it. Its two-way `Bind` queues one signal per change and reads the current +value when each signal runs. Here every binding writes only the latest value, once. A binding's change stream +skips the values in between. ### A binding made through a type parameter is not generated diff --git a/src/ReactiveUI.Binding.Shared/Observables/ViewThreadObservable.cs b/src/ReactiveUI.Binding.Shared/Observables/ViewThreadObservable.cs index 1cadcb3b..067f0f76 100644 --- a/src/ReactiveUI.Binding.Shared/Observables/ViewThreadObservable.cs +++ b/src/ReactiveUI.Binding.Shared/Observables/ViewThreadObservable.cs @@ -2,8 +2,6 @@ // 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.Collections.Concurrent; - #if REACTIVE_SHIM namespace ReactiveUI.Binding.Reactive.Observables; #else @@ -27,36 +25,32 @@ public IDisposable Subscribe(IObserver observer) return sink; } - /// One notification waiting for the owning thread. - /// Which of the three notifications this is. - /// The value, for a next notification. - /// The error, for an error notification. - private readonly struct Notification(NotificationKind kind, T? value, Exception? error) - { - /// Gets which of the three notifications this is. - public NotificationKind Kind { get; } = kind; - - /// Gets the value, for a next notification. - public T? Value { get; } = value; - - /// Gets the error, for an error notification. - public Exception? Error { get; } = error; - } - - /// The subscription that writes inline on the owning thread and queues everything else. + /// The subscription that writes inline on the owning thread and holds the latest value for it otherwise. /// The observer applying the write. /// The object the write lands on. /// The invoker for the thread that owns . private sealed class Sink(IObserver observer, object target, IViewThreadInvoker invoker) : IObserver, IDisposable { - /// The notifications waiting for the owning thread, created on the first write that has to wait. - private ConcurrentQueue? _queue; + /// Guards the waiting notifications and the scheduled flag. + private readonly Lock _gate = new(); /// The upstream subscription, or null once disposed. private IDisposable? _upstream; - /// How many notifications are queued and not yet delivered. - private int _pending; + /// The latest value waiting for the owning thread. + private T? _value; + + /// Whether holds a value. + private bool _hasValue; + + /// The error waiting for the owning thread, or null. + private Exception? _error; + + /// Whether completion is waiting for the owning thread. + private bool _completed; + + /// Whether a drain is scheduled or running. + private bool _scheduled; /// Non-zero once the subscription is disposed. private int _disposed; @@ -69,37 +63,28 @@ private sealed class Sink(IObserver observer, object target, IViewThreadInvok /// public void OnNext(T value) { - if (TryDeliverInline()) + if (Admit(NotificationKind.Next, value, null)) { observer.OnNext(value); - return; } - - Enqueue(new(NotificationKind.Next, value, null)); } /// public void OnError(Exception error) { - if (TryDeliverInline()) + if (Admit(NotificationKind.Error, default, error)) { observer.OnError(error); - return; } - - Enqueue(new(NotificationKind.Error, default, error)); } /// public void OnCompleted() { - if (TryDeliverInline()) + if (Admit(NotificationKind.Completed, default, null)) { observer.OnCompleted(); - return; } - - Enqueue(new(NotificationKind.Completed, default, null)); } /// @@ -114,55 +99,72 @@ public void Dispose() Interlocked.Exchange(ref _upstream, null)!.Dispose(); } - /// Delivers the queued notifications in order, on whichever thread the invoker or main thread runs this. - private void Drain() + /// Decides whether a notification runs on the calling thread, and holds it for the owning thread otherwise. + /// Which notification arrived. + /// The value, for a next notification. + /// The error, for an error notification. + /// when the caller should deliver the notification now. + private bool Admit(NotificationKind kind, T? value, Exception? error) { - var queue = Volatile.Read(ref _queue)!; - - do + if (Volatile.Read(ref _disposed) != 0) { - // The pending count is only raised after an enqueue, so there is always a notification to take. - _ = queue.TryDequeue(out var notification); + return false; + } - if (Volatile.Read(ref _disposed) == 0) + bool startDrain; + lock (_gate) + { + if (!_scheduled && invoker.CheckAccess(target)) { - Deliver(notification); + return true; } - } - while (Interlocked.Decrement(ref _pending) != 0); - } - /// Determines whether a notification can be delivered on the calling thread now. - /// when disposed, when earlier notifications are still queued, or when the caller is not on the owning thread. - private bool TryDeliverInline() => - Volatile.Read(ref _disposed) == 0 - && Volatile.Read(ref _pending) == 0 - && invoker.CheckAccess(target); - - /// Queues a notification and, when nothing is draining, schedules a drain. - /// The notification to queue. - private void Enqueue(in Notification notification) - { - if (Volatile.Read(ref _disposed) != 0) - { - return; + Hold(kind, value, error); + startDrain = !_scheduled; + _scheduled = true; } - var queue = Volatile.Read(ref _queue); - if (queue is null) + if (startDrain) { - // The source delivers one notification at a time, so only this thread ever creates the queue. - queue = new(); - Volatile.Write(ref _queue, queue); + ScheduleDrain(); } - queue.Enqueue(notification); + return false; + } - if (Interlocked.Increment(ref _pending) != 1) + /// Holds a notification until the drain runs. + /// Which notification arrived. + /// The value, for a next notification. + /// The error, for an error notification. + private void Hold(NotificationKind kind, T? value, Exception? error) + { + switch (kind) { - return; + case NotificationKind.Next: + { + // A newer value replaces a waiting one, so an echo of an earlier write never writes an old value back. + _value = value; + _hasValue = true; + break; + } + + case NotificationKind.Error: + { + _error = error; + break; + } + + default: + { + _completed = true; + break; + } } + } + /// Schedules the drain on the host's main thread when one is set, and through the invoker otherwise. + private void ScheduleDrain() + { var mainThread = BindingSchedulers.MainThread; if (mainThread is null) { @@ -179,29 +181,62 @@ private void Enqueue(in Notification notification) }); } - /// Hands one notification to the observer. - /// The notification to deliver. - private void Deliver(in Notification notification) + /// Delivers what is waiting until nothing is left, including anything held while a write runs. + private void Drain() { - switch (notification.Kind) + while (true) { - case NotificationKind.Next: - { - observer.OnNext(notification.Value!); - break; - } + T? value; + bool hasValue; + Exception? error; + bool completed; - case NotificationKind.Error: + lock (_gate) { - observer.OnError(notification.Error!); - break; + if (!_hasValue && _error is null && !_completed) + { + _scheduled = false; + return; + } + + value = _value; + hasValue = _hasValue; + error = _error; + completed = _completed; + _value = default; + _hasValue = false; + _error = null; + _completed = false; } - default: + if (Volatile.Read(ref _disposed) != 0) { - observer.OnCompleted(); - break; + continue; } + + Deliver(value, hasValue, error, completed); + } + } + + /// Hands the waiting value, then any terminal notification, to the observer. + /// The waiting value. + /// Whether a value was waiting. + /// The waiting error, or null. + /// Whether completion was waiting. + private void Deliver(T? value, bool hasValue, Exception? error, bool completed) + { + if (hasValue) + { + observer.OnNext(value!); + } + + if (error is not null) + { + observer.OnError(error); + } + else if (completed) + { + observer.OnCompleted(); } } } diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RuntimeExecution/ViewWriteSchedulingRuntimeTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RuntimeExecution/ViewWriteSchedulingRuntimeTests.cs index 66eb26e4..718156ce 100644 --- a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RuntimeExecution/ViewWriteSchedulingRuntimeTests.cs +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RuntimeExecution/ViewWriteSchedulingRuntimeTests.cs @@ -21,6 +21,9 @@ public class ViewWriteSchedulingRuntimeTests /// What a scenario returns when the write went through the platform's own dispatcher. private const string Dispatched = "dispatched"; + /// What a burst scenario returns when both sides end on the latest value. + private const string Settled = "settled"; + /// Bindings onto stand-ins for WPF, WinForms and MAUI views, and onto a plain object. private const string SchedulingSource = """ using System; @@ -88,6 +91,40 @@ public class WpfView : System.Windows.Threading.DispatcherObject public string DisplayName { get; set; } = ""; } + public class TwoWayWpfView : System.Windows.Threading.DispatcherObject, INotifyPropertyChanged + { + private string _displayName = ""; + + public event PropertyChangedEventHandler PropertyChanged; + + public int Writes { get; private set; } + + public string DisplayName + { + get { return _displayName; } + set + { + Writes++; + if (Writes > 1000) + { + throw new InvalidOperationException("The binding kept writing."); + } + + _displayName = value; + var handler = PropertyChanged; + if (handler != null) + { + handler(this, new PropertyChangedEventArgs("DisplayName")); + } + } + } + } + + public class BoundWpfView : TwoWayWpfView, IViewFor + { + public object ViewModel { get; set; } + } + public class WinFormsView : System.Windows.Forms.Control { public string DisplayName { get; set; } = ""; @@ -262,6 +299,45 @@ public static string OneWayToAMauiViewWithNoDispatcher() return Describe(view.DisplayName, false, 0); } + public static string TwoWayBurstFromAnotherThread() + { + var viewModel = new MyViewModel(); + var view = new TwoWayWpfView(); + view.Dispatcher.Holds = true; + var binding = viewModel.BindTwoWay(view, x => x.Name, x => x.DisplayName); + + return RunBurst(viewModel, view); + } + + public static string ViewFirstBindBurstFromAnotherThread() + { + var viewModel = new MyViewModel(); + var view = new BoundWpfView(); + view.Dispatcher.Holds = true; + var binding = view.Bind(viewModel, x => x.Name, x => x.DisplayName); + + return RunBurst(viewModel, view); + } + + private static string RunBurst(MyViewModel viewModel, TwoWayWpfView view) + { + try + { + view.Dispatcher.Pump(view); + viewModel.Name = "A"; + viewModel.Name = "B"; + view.Dispatcher.Pump(view); + } + catch (Exception) + { + return "kept writing"; + } + + return viewModel.Name == "B" && view.DisplayName == "B" + ? "settled" + : "stale: " + viewModel.Name + " / " + view.DisplayName; + } + private static string Describe(string written, bool scheduled, int dispatched) { if (written != "changed") @@ -288,14 +364,40 @@ public enum DispatcherPriority public class Dispatcher { + private readonly System.Collections.Generic.Queue _held = new System.Collections.Generic.Queue(); + public int Posts { get; private set; } + public bool Holds { get; set; } + public object BeginInvoke(DispatcherPriority priority, Delegate method, object arg) { Posts++; + if (Holds) + { + _held.Enqueue(() => method.DynamicInvoke(arg)); + return null; + } + method.DynamicInvoke(arg); return null; } + + public void Pump(DispatcherObject owner) + { + owner.HasAccess = true; + try + { + while (_held.Count > 0) + { + _held.Dequeue()(); + } + } + finally + { + owner.HasAccess = false; + } + } } public class DispatcherObject @@ -418,6 +520,18 @@ public async Task BindOneWay_ToAMauiObjectFromAnotherThread_GoesThroughItsDispat public async Task BindOneWay_ToAMauiObjectWithNoDispatcher_WritesInline() => await Assert.That(await RunScenarioAsync("OneWayToAMauiViewWithNoDispatcher")).IsEqualTo(Inline); + /// Two changes from another thread before the view's thread runs leave a two-way binding on the latest value. + /// A task representing the asynchronous test operation. + [Test] + public async Task BindTwoWay_ABurstFromAnotherThread_SettlesOnTheLatestValue() => + await Assert.That(await RunScenarioAsync("TwoWayBurstFromAnotherThread")).IsEqualTo(Settled); + + /// Two changes from another thread before the view's thread runs leave a view-first binding on the latest value. + /// A task representing the asynchronous test operation. + [Test] + public async Task Bind_ABurstFromAnotherThread_SettlesOnTheLatestValue() => + await Assert.That(await RunScenarioAsync("ViewFirstBindBurstFromAnotherThread")).IsEqualTo(Settled); + /// Compiles the scenario, runs one of its entry points, and reports where the write was delivered. /// The static method on the scenario's Usage class to run. /// What the entry point reported. diff --git a/src/tests/ReactiveUI.Binding.Tests/Observables/ViewThreadObservableTests.cs b/src/tests/ReactiveUI.Binding.Tests/Observables/ViewThreadObservableTests.cs index 15a11850..0ab815b5 100644 --- a/src/tests/ReactiveUI.Binding.Tests/Observables/ViewThreadObservableTests.cs +++ b/src/tests/ReactiveUI.Binding.Tests/Observables/ViewThreadObservableTests.cs @@ -71,10 +71,10 @@ public async Task OnNext_FromAnotherThread_WaitsForTheInvoker() } } - /// A burst from another thread posts one drain and keeps its order. + /// A burst from another thread posts one write, and that write carries only the latest value. /// A task representing the asynchronous test operation. [Test] - public async Task OnNext_ABurstFromAnotherThread_PostsOnceAndKeepsTheOrder() + public async Task OnNext_ABurstFromAnotherThread_WritesOnlyTheLatestValue() { var invoker = new StubViewThreadInvoker(); var (source, observer, subscription) = Subscribe(invoker); @@ -88,14 +88,14 @@ public async Task OnNext_ABurstFromAnotherThread_PostsOnceAndKeepsTheOrder() invoker.RunPosted(); await Assert.That(invoker.PostCount).IsEqualTo(1); - await Assert.That(string.Join(",", observer.Values)).IsEqualTo("first,second,third"); + await Assert.That(string.Join(",", observer.Values)).IsEqualTo(Third); } } - /// A value on the owning thread waits behind values still queued from another thread. + /// A value on the owning thread replaces a value still waiting from another thread. /// A task representing the asynchronous test operation. [Test] - public async Task OnNext_OnTheOwningThreadBehindQueuedValues_WaitsItsTurn() + public async Task OnNext_OnTheOwningThreadWhileAWriteWaits_ReplacesTheWaitingValue() { var invoker = new StubViewThreadInvoker(); var (source, observer, subscription) = Subscribe(invoker); @@ -111,7 +111,27 @@ public async Task OnNext_OnTheOwningThreadBehindQueuedValues_WaitsItsTurn() invoker.RunPosted(); await Assert.That(invoker.PostCount).IsEqualTo(1); - await Assert.That(string.Join(",", observer.Values)).IsEqualTo("first,second"); + await Assert.That(string.Join(",", observer.Values)).IsEqualTo(Second); + } + } + + /// A value that waits is written before the completion that follows it. + /// A task representing the asynchronous test operation. + [Test] + public async Task OnCompleted_AfterAWaitingValue_WritesTheValueFirst() + { + var invoker = new StubViewThreadInvoker(); + var completedAfter = string.Empty; + var source = new ManualObservable(); + var observer = new RecordingObserver(); + + using (new ViewThreadObservable(source, Target, invoker).Subscribe(new CompletionOrderObserver(observer, () => completedAfter = string.Join(",", observer.Values)))) + { + source.Observer?.OnNext(First); + source.Observer?.OnCompleted(); + invoker.RunPosted(); + + await Assert.That(completedAfter).IsEqualTo(First); } } @@ -302,6 +322,24 @@ private static (ManualObservable Source, RecordingObserver Obser return (source, observer, new ViewThreadObservable(source, Target, invoker).Subscribe(observer)); } + /// Forwards values to a recorder and reports what it held when completion arrived. + /// The recorder that receives each value. + /// Runs when completion arrives. + private sealed class CompletionOrderObserver(RecordingObserver inner, Action onCompleted) : IObserver + { + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnCompleted() => onCompleted(); + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnError(Exception error) => inner.OnError(error); + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnNext(string value) => inner.OnNext(value); + } + /// A source that counts how often its subscription is disposed. private sealed class CountingObservable : IObservable { From f48f6e95164ec13fe88b805ae1ea409d9f6ba440 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:26:48 +1000 Subject: [PATCH 2/5] test(benchmarks): add interaction and contended WhenChanged benchmarks - InteractionBenchmark measures asking a question through three handlers, and registering and removing handlers. - WhenChangedContentionBenchmark measures two threads changing one observed property at the same time. --- .../InteractionBenchmark.cs | 92 +++++++++++++++++++ .../WhenChangedContentionBenchmark.cs | 60 ++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 src/benchmarks/ReactiveUI.Binding.Benchmarks/InteractionBenchmark.cs create mode 100644 src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenChangedContentionBenchmark.cs diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/InteractionBenchmark.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/InteractionBenchmark.cs new file mode 100644 index 00000000..0613ef6a --- /dev/null +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/InteractionBenchmark.cs @@ -0,0 +1,92 @@ +// 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 BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Binding.Benchmarks; + +/// Interaction benchmarks: asking a question, and registering and removing handlers. +#if BENCH_NETFX +[SimpleJob(RuntimeMoniker.Net462)] +#endif +[SimpleJob(RuntimeMoniker.Net80)] +[SimpleJob(RuntimeMoniker.Net10_0)] +[SimpleJob(RuntimeMoniker.Net11_0)] +[SimpleJob(RuntimeMoniker.NativeAot10_0, id: nameof(RuntimeMoniker.NativeAot10_0))] +[SimpleJob(RuntimeMoniker.NativeAot11_0, id: nameof(RuntimeMoniker.NativeAot11_0))] +[MemoryDiagnoser] +#if !BENCH_NETFX +[EventPipeProfiler(EventPipeProfile.GcVerbose)] +#endif +[MarkdownExporterAttribute.GitHub] +public class InteractionBenchmark +{ + /// How many questions each Handle benchmark asks. + private const int QuestionCount = 1_000; + + /// How many handlers each registration benchmark adds and removes. + private const int RegistrationCount = 10; + + /// The interaction under measurement, with three handlers registered. + private Interaction _interaction = null!; + + /// The handler registrations, released after each iteration. + private IDisposable[] _registrations = null!; + + /// Registers one handler that answers and two later ones that pass, so every question walks all three. + [IterationSetup] + public void Setup() + { + _interaction = new(); + _registrations = + [ + _interaction.RegisterHandler(static context => context.SetOutput(context.Input.Length)), + _interaction.RegisterHandler(static _ => { }), + _interaction.RegisterHandler(static _ => { }), + ]; + } + + /// Releases the handler registrations. + [IterationCleanup] + public void Cleanup() + { + for (var i = 0; i < _registrations.Length; i++) + { + _registrations[i].Dispose(); + } + } + + /// Asks N questions that fall through two handlers to the one that answers. + /// The sum of the answers. + [Benchmark(Description = "Handle")] + public async Task Handle() + { + var total = 0; + for (var i = 0; i < QuestionCount; i++) + { + total += await _interaction.Handle("question").ConfigureAwait(false); + } + + return total; + } + + /// Registers ten more handlers beside the three already registered, then removes them. + [Benchmark(Description = "10x Register/Dispose")] + public void RegisterAndDispose() + { + var registrations = new IDisposable[RegistrationCount]; + + for (var i = 0; i < RegistrationCount; i++) + { + registrations[i] = _interaction.RegisterHandler(static context => context.SetOutput(0)); + } + + for (var i = 0; i < RegistrationCount; i++) + { + registrations[i].Dispose(); + } + } +} diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenChangedContentionBenchmark.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenChangedContentionBenchmark.cs new file mode 100644 index 00000000..cf4f557e --- /dev/null +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenChangedContentionBenchmark.cs @@ -0,0 +1,60 @@ +// 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 BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Binding.Benchmarks; + +/// WhenChanged with two threads changing the observed property at the same time. +#if BENCH_NETFX +[SimpleJob(RuntimeMoniker.Net462)] +#endif +[SimpleJob(RuntimeMoniker.Net80)] +[SimpleJob(RuntimeMoniker.Net10_0)] +[SimpleJob(RuntimeMoniker.Net11_0)] +[SimpleJob(RuntimeMoniker.NativeAot10_0, id: nameof(RuntimeMoniker.NativeAot10_0))] +[SimpleJob(RuntimeMoniker.NativeAot11_0, id: nameof(RuntimeMoniker.NativeAot11_0))] +[MemoryDiagnoser] +#if !BENCH_NETFX +[EventPipeProfiler(EventPipeProfile.GcVerbose)] +#endif +[MarkdownExporterAttribute.GitHub] +public class WhenChangedContentionBenchmark +{ + /// How many property changes each writer drives. + private const int PropertyChangeCount = 1_000; + + /// The view model under observation. + private BenchmarkViewModel _vm = null!; + + /// Builds a fresh view model before each iteration. + [IterationSetup] + public void Setup() => + _vm = new() { Name = "Initial" }; + + /// Two writers on separate threads, each driving N changes through one subscription. + [Benchmark(Description = "Two Writers")] + public void TwoWriters() + { + var last = string.Empty; + using var sub = _vm.WhenChanged(x => x.Name) + .Subscribe(v => last = v); + + Parallel.Invoke( + () => Write("A_"), + () => Write("B_")); + } + + /// Drives N changes, each to a value the other writer never uses. + /// The prefix that keeps this writer's values distinct. + private void Write(string prefix) + { + for (var i = 0; i < PropertyChangeCount; i++) + { + _vm.Name = prefix + i; + } + } +} From e526c73df934f81d2d8bee6bc4d48448c2ed0df8 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:26:48 +1000 Subject: [PATCH 3/5] perf(binding): drop locks that a lock-free path can replace - WhenChanged hands a change that arrives during another emit to that emit, so no thread waits on a lock and an observer that waits on such a thread does not deadlock. - An observer that changes the property it observes gets the change after it returns, not inside its own call. - Interaction keeps its handlers in an array replaced on each change, so Handle reads them without a lock or a copy. - Interaction and the binding change stream share one copy-on-write array helper. - RxBindingBuilder tracks initialization with a volatile flag instead of a lock. - BindingHooks reads its cached set with a volatile read and keeps its lock for the resolve after a refresh. - The before-change observables keep their lock, because the value must be read on the raising thread before it writes. --- .../PublicAPI/net10.0/PublicAPI.txt | 2 +- .../PublicAPI/net11.0/PublicAPI.txt | 2 +- .../PublicAPI/net462/PublicAPI.txt | 2 +- .../PublicAPI/net47/PublicAPI.txt | 2 +- .../PublicAPI/net471/PublicAPI.txt | 2 +- .../PublicAPI/net472/PublicAPI.txt | 2 +- .../PublicAPI/net48/PublicAPI.txt | 2 +- .../PublicAPI/net481/PublicAPI.txt | 2 +- .../PublicAPI/net8.0/PublicAPI.txt | 2 +- .../PublicAPI/net9.0/PublicAPI.txt | 2 +- .../Bindings/BindingHooks.cs | 6 +- .../Builder/RxBindingBuilder.cs | 44 ++++----- .../Helpers/CopyOnWriteArray.cs | 57 ++++++++++++ .../Interactions/Interaction.cs | 46 ++++------ .../Observables/AppliedChangeObservable.cs | 41 +-------- .../Observables/PluginPropertyObservable.cs | 2 + .../Observables/PropertyChangingObservable.cs | 3 +- .../Observables/PropertyObservable.cs | 64 ++++++++----- .../PublicAPI/net10.0/PublicAPI.txt | 2 +- .../PublicAPI/net11.0/PublicAPI.txt | 2 +- .../PublicAPI/net462/PublicAPI.txt | 2 +- .../PublicAPI/net47/PublicAPI.txt | 2 +- .../PublicAPI/net471/PublicAPI.txt | 2 +- .../PublicAPI/net472/PublicAPI.txt | 2 +- .../PublicAPI/net48/PublicAPI.txt | 2 +- .../PublicAPI/net481/PublicAPI.txt | 2 +- .../PublicAPI/net8.0/PublicAPI.txt | 2 +- .../PublicAPI/net9.0/PublicAPI.txt | 2 +- ...ObservableInitialEmitSerializationTests.cs | 92 ++++++++++++++++--- 29 files changed, 240 insertions(+), 155 deletions(-) create mode 100644 src/ReactiveUI.Binding.Shared/Helpers/CopyOnWriteArray.cs diff --git a/src/ReactiveUI.Binding.Reactive/PublicAPI/net10.0/PublicAPI.txt b/src/ReactiveUI.Binding.Reactive/PublicAPI/net10.0/PublicAPI.txt index 2236e881..8d916186 100644 --- a/src/ReactiveUI.Binding.Reactive/PublicAPI/net10.0/PublicAPI.txt +++ b/src/ReactiveUI.Binding.Reactive/PublicAPI/net10.0/PublicAPI.txt @@ -376,7 +376,7 @@ namespace ReactiveUI.Binding.Reactive public override int GetAffinityForObjects() { } public override bool TryConvert(int from, object? conversionHint, out string? result) { } } - [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")] + [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")] public class Interaction : ReactiveUI.Binding.Reactive.IInteraction { public Interaction() { } diff --git a/src/ReactiveUI.Binding.Reactive/PublicAPI/net11.0/PublicAPI.txt b/src/ReactiveUI.Binding.Reactive/PublicAPI/net11.0/PublicAPI.txt index 2236e881..8d916186 100644 --- a/src/ReactiveUI.Binding.Reactive/PublicAPI/net11.0/PublicAPI.txt +++ b/src/ReactiveUI.Binding.Reactive/PublicAPI/net11.0/PublicAPI.txt @@ -376,7 +376,7 @@ namespace ReactiveUI.Binding.Reactive public override int GetAffinityForObjects() { } public override bool TryConvert(int from, object? conversionHint, out string? result) { } } - [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")] + [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")] public class Interaction : ReactiveUI.Binding.Reactive.IInteraction { public Interaction() { } diff --git a/src/ReactiveUI.Binding.Reactive/PublicAPI/net462/PublicAPI.txt b/src/ReactiveUI.Binding.Reactive/PublicAPI/net462/PublicAPI.txt index e9301bb6..d881b992 100644 --- a/src/ReactiveUI.Binding.Reactive/PublicAPI/net462/PublicAPI.txt +++ b/src/ReactiveUI.Binding.Reactive/PublicAPI/net462/PublicAPI.txt @@ -354,7 +354,7 @@ namespace ReactiveUI.Binding.Reactive public override int GetAffinityForObjects() { } public override bool TryConvert(int from, object? conversionHint, out string? result) { } } - [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")] + [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")] public class Interaction : ReactiveUI.Binding.Reactive.IInteraction { public Interaction() { } diff --git a/src/ReactiveUI.Binding.Reactive/PublicAPI/net47/PublicAPI.txt b/src/ReactiveUI.Binding.Reactive/PublicAPI/net47/PublicAPI.txt index e9301bb6..d881b992 100644 --- a/src/ReactiveUI.Binding.Reactive/PublicAPI/net47/PublicAPI.txt +++ b/src/ReactiveUI.Binding.Reactive/PublicAPI/net47/PublicAPI.txt @@ -354,7 +354,7 @@ namespace ReactiveUI.Binding.Reactive public override int GetAffinityForObjects() { } public override bool TryConvert(int from, object? conversionHint, out string? result) { } } - [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")] + [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")] public class Interaction : ReactiveUI.Binding.Reactive.IInteraction { public Interaction() { } diff --git a/src/ReactiveUI.Binding.Reactive/PublicAPI/net471/PublicAPI.txt b/src/ReactiveUI.Binding.Reactive/PublicAPI/net471/PublicAPI.txt index e9301bb6..d881b992 100644 --- a/src/ReactiveUI.Binding.Reactive/PublicAPI/net471/PublicAPI.txt +++ b/src/ReactiveUI.Binding.Reactive/PublicAPI/net471/PublicAPI.txt @@ -354,7 +354,7 @@ namespace ReactiveUI.Binding.Reactive public override int GetAffinityForObjects() { } public override bool TryConvert(int from, object? conversionHint, out string? result) { } } - [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")] + [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")] public class Interaction : ReactiveUI.Binding.Reactive.IInteraction { public Interaction() { } diff --git a/src/ReactiveUI.Binding.Reactive/PublicAPI/net472/PublicAPI.txt b/src/ReactiveUI.Binding.Reactive/PublicAPI/net472/PublicAPI.txt index e9301bb6..d881b992 100644 --- a/src/ReactiveUI.Binding.Reactive/PublicAPI/net472/PublicAPI.txt +++ b/src/ReactiveUI.Binding.Reactive/PublicAPI/net472/PublicAPI.txt @@ -354,7 +354,7 @@ namespace ReactiveUI.Binding.Reactive public override int GetAffinityForObjects() { } public override bool TryConvert(int from, object? conversionHint, out string? result) { } } - [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")] + [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")] public class Interaction : ReactiveUI.Binding.Reactive.IInteraction { public Interaction() { } diff --git a/src/ReactiveUI.Binding.Reactive/PublicAPI/net48/PublicAPI.txt b/src/ReactiveUI.Binding.Reactive/PublicAPI/net48/PublicAPI.txt index e9301bb6..d881b992 100644 --- a/src/ReactiveUI.Binding.Reactive/PublicAPI/net48/PublicAPI.txt +++ b/src/ReactiveUI.Binding.Reactive/PublicAPI/net48/PublicAPI.txt @@ -354,7 +354,7 @@ namespace ReactiveUI.Binding.Reactive public override int GetAffinityForObjects() { } public override bool TryConvert(int from, object? conversionHint, out string? result) { } } - [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")] + [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")] public class Interaction : ReactiveUI.Binding.Reactive.IInteraction { public Interaction() { } diff --git a/src/ReactiveUI.Binding.Reactive/PublicAPI/net481/PublicAPI.txt b/src/ReactiveUI.Binding.Reactive/PublicAPI/net481/PublicAPI.txt index e9301bb6..d881b992 100644 --- a/src/ReactiveUI.Binding.Reactive/PublicAPI/net481/PublicAPI.txt +++ b/src/ReactiveUI.Binding.Reactive/PublicAPI/net481/PublicAPI.txt @@ -354,7 +354,7 @@ namespace ReactiveUI.Binding.Reactive public override int GetAffinityForObjects() { } public override bool TryConvert(int from, object? conversionHint, out string? result) { } } - [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")] + [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")] public class Interaction : ReactiveUI.Binding.Reactive.IInteraction { public Interaction() { } diff --git a/src/ReactiveUI.Binding.Reactive/PublicAPI/net8.0/PublicAPI.txt b/src/ReactiveUI.Binding.Reactive/PublicAPI/net8.0/PublicAPI.txt index 2236e881..8d916186 100644 --- a/src/ReactiveUI.Binding.Reactive/PublicAPI/net8.0/PublicAPI.txt +++ b/src/ReactiveUI.Binding.Reactive/PublicAPI/net8.0/PublicAPI.txt @@ -376,7 +376,7 @@ namespace ReactiveUI.Binding.Reactive public override int GetAffinityForObjects() { } public override bool TryConvert(int from, object? conversionHint, out string? result) { } } - [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")] + [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")] public class Interaction : ReactiveUI.Binding.Reactive.IInteraction { public Interaction() { } diff --git a/src/ReactiveUI.Binding.Reactive/PublicAPI/net9.0/PublicAPI.txt b/src/ReactiveUI.Binding.Reactive/PublicAPI/net9.0/PublicAPI.txt index 2236e881..8d916186 100644 --- a/src/ReactiveUI.Binding.Reactive/PublicAPI/net9.0/PublicAPI.txt +++ b/src/ReactiveUI.Binding.Reactive/PublicAPI/net9.0/PublicAPI.txt @@ -376,7 +376,7 @@ namespace ReactiveUI.Binding.Reactive public override int GetAffinityForObjects() { } public override bool TryConvert(int from, object? conversionHint, out string? result) { } } - [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")] + [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")] public class Interaction : ReactiveUI.Binding.Reactive.IInteraction { public Interaction() { } diff --git a/src/ReactiveUI.Binding.Shared/Bindings/BindingHooks.cs b/src/ReactiveUI.Binding.Shared/Bindings/BindingHooks.cs index be6c57f8..40f05ddd 100644 --- a/src/ReactiveUI.Binding.Shared/Bindings/BindingHooks.cs +++ b/src/ReactiveUI.Binding.Shared/Bindings/BindingHooks.cs @@ -29,6 +29,10 @@ namespace ReactiveUI.Binding; public static class BindingHooks { /// Guards while it is (re)resolved. + /// + /// Only the first resolve after a refresh takes it. Holding it while the locator is read stops a resolve + /// that races from publishing the set that refresh dropped. + /// private static readonly Lock Gate = new(); /// The resolved hooks, or null while none has been resolved yet. @@ -87,7 +91,7 @@ public static bool ShouldBind( [MethodImpl(MethodImplOptions.AggressiveInlining)] private static IPropertyBindingHook[] Resolve() { - var resolved = _hooks; + var resolved = Volatile.Read(ref _hooks); if (resolved is not null) { return resolved; diff --git a/src/ReactiveUI.Binding.Shared/Builder/RxBindingBuilder.cs b/src/ReactiveUI.Binding.Shared/Builder/RxBindingBuilder.cs index 17d4fd30..ec176f5a 100644 --- a/src/ReactiveUI.Binding.Shared/Builder/RxBindingBuilder.cs +++ b/src/ReactiveUI.Binding.Shared/Builder/RxBindingBuilder.cs @@ -20,11 +20,8 @@ namespace ReactiveUI.Binding.Builder; /// public static class RxBindingBuilder { - /// Synchronization gate for initialization and reset operations. - private static readonly Lock _resetLock = new(); - - /// Tracks whether ReactiveUI.Binding has been initialized (0 = not initialized, 1 = initialized). - private static int _hasBeenInitialized; // 0 = false, 1 = true + /// Whether ReactiveUI.Binding has been initialized: 0 until runs, 1 after. + private static int _hasBeenInitialized; /// Creates a new using the current Splat locator. /// A new builder instance. @@ -35,18 +32,17 @@ public static ReactiveUIBindingBuilder CreateReactiveUIBindingBuilder() => /// Thrown if BuildApp() has not been called. public static void EnsureInitialized() { - lock (_resetLock) + if (Volatile.Read(ref _hasBeenInitialized) != 0) { - if (_hasBeenInitialized == 0) - { - throw new InvalidOperationException( - "ReactiveUI.Binding has not been initialized. You must initialize using the builder pattern.\n\n" - + "Example:\n" - + "RxBindingBuilder.CreateReactiveUIBindingBuilder()\n" - + " .WithCoreServices()\n" - + " .BuildApp();"); - } + return; } + + throw new InvalidOperationException( + "ReactiveUI.Binding has not been initialized. You must initialize using the builder pattern.\n\n" + + "Example:\n" + + "RxBindingBuilder.CreateReactiveUIBindingBuilder()\n" + + " .WithCoreServices()\n" + + " .BuildApp();"); } /// Resets the initialization state for testing purposes only. @@ -55,20 +51,12 @@ public static void EnsureInitialized() /// internal static void ResetForTesting() { - lock (_resetLock) - { - AppBuilder.ResetBuilderStateForTests(); - AppLocator.SetLocator(new ModernDependencyResolver()); - _hasBeenInitialized = 0; - } + AppBuilder.ResetBuilderStateForTests(); + AppLocator.SetLocator(new ModernDependencyResolver()); + Volatile.Write(ref _hasBeenInitialized, 0); } /// Marks ReactiveUI.Binding as initialized. Called by . - internal static void MarkAsInitialized() - { - lock (_resetLock) - { - _hasBeenInitialized = 1; - } - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static void MarkAsInitialized() => Volatile.Write(ref _hasBeenInitialized, 1); } diff --git a/src/ReactiveUI.Binding.Shared/Helpers/CopyOnWriteArray.cs b/src/ReactiveUI.Binding.Shared/Helpers/CopyOnWriteArray.cs new file mode 100644 index 00000000..2867c374 --- /dev/null +++ b/src/ReactiveUI.Binding.Shared/Helpers/CopyOnWriteArray.cs @@ -0,0 +1,57 @@ +// 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. + +#if REACTIVE_SHIM +namespace ReactiveUI.Binding.Reactive.Helpers; +#else +namespace ReactiveUI.Binding.Helpers; +#endif + +/// Changes an array by publishing a changed copy, so a reader walks a stable set without a lock. +internal static class CopyOnWriteArray +{ + /// Publishes a copy of the array with added at the end. + /// The element type. + /// The field holding the array. + /// The item to add. + internal static void Add(ref T[] location, T item) + { + T[] current; + T[] updated; + + do + { + current = Volatile.Read(ref location); + updated = new T[current.Length + 1]; + Array.Copy(current, updated, current.Length); + updated[current.Length] = item; + } + while (!ReferenceEquals(Interlocked.CompareExchange(ref location, updated, current), current)); + } + + /// Publishes a copy of the array without the first occurrence of . + /// The element type. + /// The field holding the array. + /// The item to remove. An item that is not there leaves the array alone. + internal static void Remove(ref T[] location, T item) + { + T[] current; + T[] updated; + + do + { + current = Volatile.Read(ref location); + var index = Array.IndexOf(current, item); + if (index < 0) + { + return; + } + + updated = new T[current.Length - 1]; + Array.Copy(current, updated, index); + Array.Copy(current, index + 1, updated, index, current.Length - index - 1); + } + while (!ReferenceEquals(Interlocked.CompareExchange(ref location, updated, current), current)); + } +} diff --git a/src/ReactiveUI.Binding.Shared/Interactions/Interaction.cs b/src/ReactiveUI.Binding.Shared/Interactions/Interaction.cs index fc82830c..de907742 100644 --- a/src/ReactiveUI.Binding.Shared/Interactions/Interaction.cs +++ b/src/ReactiveUI.Binding.Shared/Interactions/Interaction.cs @@ -29,14 +29,14 @@ namespace ReactiveUI.Binding; /// if no handler handles the interaction. /// /// -[DebuggerDisplay("Handlers = {_handlers.Count}")] +[DebuggerDisplay("Handlers = {_handlers.Length}")] public class Interaction : IInteraction { - /// The list of registered interaction handlers, invoked in reverse order during . - private readonly List, Task>> _handlers = []; - - /// Synchronization gate for thread-safe handler registration and removal. - private readonly Lock _sync = new(); + /// + /// The registered handlers, invoked in reverse order during . The array is replaced + /// rather than changed, so a question already being handled walks the set it started with. + /// + private Func, Task>[] _handlers = []; /// public IDisposable RegisterHandler(Action> handler) @@ -80,7 +80,7 @@ Task ContentHandler(IInteractionContext context) public virtual async Task Handle(TInput input) { var context = GenerateContext(input); - var handlers = GetHandlers(); + var handlers = Volatile.Read(ref _handlers); for (var i = handlers.Length - 1; i >= 0; i--) { @@ -96,13 +96,7 @@ public virtual async Task Handle(TInput input) /// Gets all registered handlers by order of registration. /// All registered handlers. - protected Func, Task>[] GetHandlers() - { - lock (_sync) - { - return [.. _handlers]; - } - } + protected Func, Task>[] GetHandlers() => [.. Volatile.Read(ref _handlers)]; /// Gets an interaction context which is used to provide information about the interaction. /// The input that is being passed in. @@ -110,25 +104,17 @@ protected Func, Task>[] GetHandlers() protected virtual IOutputContext GenerateContext(TInput input) => new InteractionContext(input); - /// Adds a handler to the internal handler list under the synchronization gate. + /// Adds a handler to the registered set. /// The handler to add. - private void AddHandler(Func, Task> handler) - { - lock (_sync) - { - _handlers.Add(handler); - } - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void AddHandler(Func, Task> handler) => + CopyOnWriteArray.Add(ref _handlers, handler); - /// Removes a handler from the internal handler list under the synchronization gate. + /// Removes the first registration of a handler from the registered set. /// The handler to remove. - private void RemoveHandler(Func, Task> handler) - { - lock (_sync) - { - _ = _handlers.Remove(handler); - } - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void RemoveHandler(Func, Task> handler) => + CopyOnWriteArray.Remove(ref _handlers, handler); /// An observer that bridges an observable sequence to a , completing the task when the observable completes or faults. /// The element type of the observable sequence. diff --git a/src/ReactiveUI.Binding.Shared/Observables/AppliedChangeObservable.cs b/src/ReactiveUI.Binding.Shared/Observables/AppliedChangeObservable.cs index 628389bf..a1aafbb9 100644 --- a/src/ReactiveUI.Binding.Shared/Observables/AppliedChangeObservable.cs +++ b/src/ReactiveUI.Binding.Shared/Observables/AppliedChangeObservable.cs @@ -42,47 +42,14 @@ public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); - IObserver[] updated; - IObserver[] current; - - do - { - current = Volatile.Read(ref _observers); - updated = new IObserver[current.Length + 1]; - Array.Copy(current, updated, current.Length); - updated[current.Length] = observer; - } - while (!ReferenceEquals(Interlocked.CompareExchange(ref _observers, updated, current), current)); - + CopyOnWriteArray.Add(ref _observers, observer); return new Subscription(this, observer); } /// Drops one observer without disturbing a change already being delivered. - /// The observer to drop. - /// - /// An observer that is not there is left alone. A subscription drops its own place once and no other path - /// reaches here, so that is a guard against a future caller rather than something the current ones do. - /// - internal void Remove(IObserver observer) - { - IObserver[] current; - IObserver[] updated; - - do - { - current = Volatile.Read(ref _observers); - var index = Array.IndexOf(current, observer); - if (index < 0) - { - return; - } - - updated = new IObserver[current.Length - 1]; - Array.Copy(current, updated, index); - Array.Copy(current, index + 1, updated, index, current.Length - index - 1); - } - while (!ReferenceEquals(Interlocked.CompareExchange(ref _observers, updated, current), current)); - } + /// The observer to drop. An observer that is not there is left alone. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void Remove(IObserver observer) => CopyOnWriteArray.Remove(ref _observers, observer); /// Releases one observer's place in the change stream. /// The stream subscribed to. diff --git a/src/ReactiveUI.Binding.Shared/Observables/PluginPropertyObservable.cs b/src/ReactiveUI.Binding.Shared/Observables/PluginPropertyObservable.cs index 090e7b32..63d1ba3a 100644 --- a/src/ReactiveUI.Binding.Shared/Observables/PluginPropertyObservable.cs +++ b/src/ReactiveUI.Binding.Shared/Observables/PluginPropertyObservable.cs @@ -98,6 +98,8 @@ internal sealed class Subscription : IDisposable, IObserver /// Serializes the initial emit with notifications arriving on other threads, so the handler always /// sees a consistent and pair whatever the timing. + /// It is a lock rather than a hand-off to the thread already emitting, because a before-change + /// notification must read the value on the raising thread before that thread writes. /// private readonly Lock _gate = new(); diff --git a/src/ReactiveUI.Binding.Shared/Observables/PropertyChangingObservable.cs b/src/ReactiveUI.Binding.Shared/Observables/PropertyChangingObservable.cs index 44cd7da5..a923becc 100644 --- a/src/ReactiveUI.Binding.Shared/Observables/PropertyChangingObservable.cs +++ b/src/ReactiveUI.Binding.Shared/Observables/PropertyChangingObservable.cs @@ -64,7 +64,8 @@ internal sealed class Subscription : IDisposable /// /// Serializes the initial emit in the constructor with concurrent /// invocations on other threads, so a racing handler emit and the constructor's initial emit do - /// not interleave on the downstream observer. + /// not interleave on the downstream observer. It is a lock rather than a hand-off to the thread + /// already emitting, because the value must be read on the raising thread before that thread writes. /// private readonly Lock _gate = new(); diff --git a/src/ReactiveUI.Binding.Shared/Observables/PropertyObservable.cs b/src/ReactiveUI.Binding.Shared/Observables/PropertyObservable.cs index dda82427..443c63be 100644 --- a/src/ReactiveUI.Binding.Shared/Observables/PropertyObservable.cs +++ b/src/ReactiveUI.Binding.Shared/Observables/PropertyObservable.cs @@ -70,11 +70,10 @@ internal sealed class Subscription : IDisposable private readonly EqualityComparer _comparer; /// - /// Serializes the initial emit in the constructor with concurrent - /// invocations on other threads, so the handler always sees a consistent - /// / snapshot regardless of timing. + /// Counts the emits asked for and not yet served. The thread that raises it from zero serves every + /// emit asked for while it runs, so emits never overlap and no thread waits for another. /// - private readonly Lock _gate = new(); + private int _pendingEmits; /// The downstream observer. Set to on disposal. private IObserver? _observer; @@ -145,33 +144,48 @@ private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) EmitCurrent(); } - /// - /// Reads the current property value under and forwards it to the downstream - /// observer when the distinct-until-changed gate allows. Holding across the - /// read-decision-emit sequence ensures the constructor's initial emit and any concurrent - /// invocation cannot interleave on the downstream observer or - /// publish a duplicate when both see the same current value. - /// + /// Reads the current property value and forwards it downstream when the distinct gate allows. + /// + /// A call made while another emit runs returns at once. The running emit reads the property again + /// before it stops, so the value is never lost and never stale. A call from inside the downstream + /// observer is delivered after that observer returns. A throw clears the count, so the next change + /// still emits. + /// private void EmitCurrent() { - lock (_gate) + if (Interlocked.Increment(ref _pendingEmits) != 1) { - var observer = Volatile.Read(ref _observer); - if (observer is null) - { - return; - } - - var value = _parent._getter(_parent._source); + return; + } - if (_parent._distinctUntilChanged && _hasValue && _comparer.Equals(value!, _lastValue!)) + var unserved = 1; + try + { + do { - return; + var observer = Volatile.Read(ref _observer); + if (observer is null) + { + return; + } + + var value = _parent._getter(_parent._source); + + if (!_parent._distinctUntilChanged || !_hasValue || !_comparer.Equals(value!, _lastValue!)) + { + _lastValue = value; + _hasValue = true; + observer.OnNext(value!); + } + + unserved = Interlocked.Add(ref _pendingEmits, -unserved); } - - _lastValue = value; - _hasValue = true; - observer.OnNext(value!); + while (unserved != 0); + } + catch + { + Volatile.Write(ref _pendingEmits, 0); + throw; } } } diff --git a/src/ReactiveUI.Binding/PublicAPI/net10.0/PublicAPI.txt b/src/ReactiveUI.Binding/PublicAPI/net10.0/PublicAPI.txt index c75d652c..5eb6a9b3 100644 --- a/src/ReactiveUI.Binding/PublicAPI/net10.0/PublicAPI.txt +++ b/src/ReactiveUI.Binding/PublicAPI/net10.0/PublicAPI.txt @@ -375,7 +375,7 @@ namespace ReactiveUI.Binding public override int GetAffinityForObjects() { } public override bool TryConvert(int from, object? conversionHint, out string? result) { } } - [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")] + [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")] public class Interaction : ReactiveUI.Binding.IInteraction { public Interaction() { } diff --git a/src/ReactiveUI.Binding/PublicAPI/net11.0/PublicAPI.txt b/src/ReactiveUI.Binding/PublicAPI/net11.0/PublicAPI.txt index c75d652c..5eb6a9b3 100644 --- a/src/ReactiveUI.Binding/PublicAPI/net11.0/PublicAPI.txt +++ b/src/ReactiveUI.Binding/PublicAPI/net11.0/PublicAPI.txt @@ -375,7 +375,7 @@ namespace ReactiveUI.Binding public override int GetAffinityForObjects() { } public override bool TryConvert(int from, object? conversionHint, out string? result) { } } - [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")] + [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")] public class Interaction : ReactiveUI.Binding.IInteraction { public Interaction() { } diff --git a/src/ReactiveUI.Binding/PublicAPI/net462/PublicAPI.txt b/src/ReactiveUI.Binding/PublicAPI/net462/PublicAPI.txt index fc4b599d..10ce6f57 100644 --- a/src/ReactiveUI.Binding/PublicAPI/net462/PublicAPI.txt +++ b/src/ReactiveUI.Binding/PublicAPI/net462/PublicAPI.txt @@ -354,7 +354,7 @@ namespace ReactiveUI.Binding public override int GetAffinityForObjects() { } public override bool TryConvert(int from, object? conversionHint, out string? result) { } } - [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")] + [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")] public class Interaction : ReactiveUI.Binding.IInteraction { public Interaction() { } diff --git a/src/ReactiveUI.Binding/PublicAPI/net47/PublicAPI.txt b/src/ReactiveUI.Binding/PublicAPI/net47/PublicAPI.txt index fc4b599d..10ce6f57 100644 --- a/src/ReactiveUI.Binding/PublicAPI/net47/PublicAPI.txt +++ b/src/ReactiveUI.Binding/PublicAPI/net47/PublicAPI.txt @@ -354,7 +354,7 @@ namespace ReactiveUI.Binding public override int GetAffinityForObjects() { } public override bool TryConvert(int from, object? conversionHint, out string? result) { } } - [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")] + [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")] public class Interaction : ReactiveUI.Binding.IInteraction { public Interaction() { } diff --git a/src/ReactiveUI.Binding/PublicAPI/net471/PublicAPI.txt b/src/ReactiveUI.Binding/PublicAPI/net471/PublicAPI.txt index fc4b599d..10ce6f57 100644 --- a/src/ReactiveUI.Binding/PublicAPI/net471/PublicAPI.txt +++ b/src/ReactiveUI.Binding/PublicAPI/net471/PublicAPI.txt @@ -354,7 +354,7 @@ namespace ReactiveUI.Binding public override int GetAffinityForObjects() { } public override bool TryConvert(int from, object? conversionHint, out string? result) { } } - [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")] + [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")] public class Interaction : ReactiveUI.Binding.IInteraction { public Interaction() { } diff --git a/src/ReactiveUI.Binding/PublicAPI/net472/PublicAPI.txt b/src/ReactiveUI.Binding/PublicAPI/net472/PublicAPI.txt index fc4b599d..10ce6f57 100644 --- a/src/ReactiveUI.Binding/PublicAPI/net472/PublicAPI.txt +++ b/src/ReactiveUI.Binding/PublicAPI/net472/PublicAPI.txt @@ -354,7 +354,7 @@ namespace ReactiveUI.Binding public override int GetAffinityForObjects() { } public override bool TryConvert(int from, object? conversionHint, out string? result) { } } - [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")] + [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")] public class Interaction : ReactiveUI.Binding.IInteraction { public Interaction() { } diff --git a/src/ReactiveUI.Binding/PublicAPI/net48/PublicAPI.txt b/src/ReactiveUI.Binding/PublicAPI/net48/PublicAPI.txt index fc4b599d..10ce6f57 100644 --- a/src/ReactiveUI.Binding/PublicAPI/net48/PublicAPI.txt +++ b/src/ReactiveUI.Binding/PublicAPI/net48/PublicAPI.txt @@ -354,7 +354,7 @@ namespace ReactiveUI.Binding public override int GetAffinityForObjects() { } public override bool TryConvert(int from, object? conversionHint, out string? result) { } } - [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")] + [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")] public class Interaction : ReactiveUI.Binding.IInteraction { public Interaction() { } diff --git a/src/ReactiveUI.Binding/PublicAPI/net481/PublicAPI.txt b/src/ReactiveUI.Binding/PublicAPI/net481/PublicAPI.txt index fc4b599d..10ce6f57 100644 --- a/src/ReactiveUI.Binding/PublicAPI/net481/PublicAPI.txt +++ b/src/ReactiveUI.Binding/PublicAPI/net481/PublicAPI.txt @@ -354,7 +354,7 @@ namespace ReactiveUI.Binding public override int GetAffinityForObjects() { } public override bool TryConvert(int from, object? conversionHint, out string? result) { } } - [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")] + [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")] public class Interaction : ReactiveUI.Binding.IInteraction { public Interaction() { } diff --git a/src/ReactiveUI.Binding/PublicAPI/net8.0/PublicAPI.txt b/src/ReactiveUI.Binding/PublicAPI/net8.0/PublicAPI.txt index c75d652c..5eb6a9b3 100644 --- a/src/ReactiveUI.Binding/PublicAPI/net8.0/PublicAPI.txt +++ b/src/ReactiveUI.Binding/PublicAPI/net8.0/PublicAPI.txt @@ -375,7 +375,7 @@ namespace ReactiveUI.Binding public override int GetAffinityForObjects() { } public override bool TryConvert(int from, object? conversionHint, out string? result) { } } - [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")] + [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")] public class Interaction : ReactiveUI.Binding.IInteraction { public Interaction() { } diff --git a/src/ReactiveUI.Binding/PublicAPI/net9.0/PublicAPI.txt b/src/ReactiveUI.Binding/PublicAPI/net9.0/PublicAPI.txt index c75d652c..5eb6a9b3 100644 --- a/src/ReactiveUI.Binding/PublicAPI/net9.0/PublicAPI.txt +++ b/src/ReactiveUI.Binding/PublicAPI/net9.0/PublicAPI.txt @@ -375,7 +375,7 @@ namespace ReactiveUI.Binding public override int GetAffinityForObjects() { } public override bool TryConvert(int from, object? conversionHint, out string? result) { } } - [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")] + [System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")] public class Interaction : ReactiveUI.Binding.IInteraction { public Interaction() { } diff --git a/src/tests/ReactiveUI.Binding.Tests/Observables/PropertyObservableInitialEmitSerializationTests.cs b/src/tests/ReactiveUI.Binding.Tests/Observables/PropertyObservableInitialEmitSerializationTests.cs index 5b772a5f..5c4f95b2 100644 --- a/src/tests/ReactiveUI.Binding.Tests/Observables/PropertyObservableInitialEmitSerializationTests.cs +++ b/src/tests/ReactiveUI.Binding.Tests/Observables/PropertyObservableInitialEmitSerializationTests.cs @@ -28,12 +28,15 @@ public class PropertyObservableInitialEmitSerializationTests /// The property value a competing thread writes. private const string ReplacementName = "Bob"; + /// The property value written after a read has failed. + private const string ThirdName = "Carol"; + /// - /// How long to give a competing thread to complete its emit while the initial emit is still on the - /// stack. A serialized subscription blocks that thread for the whole window, so the wait always - /// expires; an unserialized one lets it through in microseconds. + /// How long the initial emit waits for a competing thread to finish its write. The subscription never + /// makes that thread wait, so the join ends as soon as the write does; the bound only turns a deadlock + /// into a failure. /// - private const int InterleaveWindowMilliseconds = 500; + private const int CompetitorTimeoutMilliseconds = 10_000; /// /// Subscriptions the unforced sweep builds. Sized from measurement: against unserialized code this @@ -75,8 +78,8 @@ public async Task Subscribe_PropertyChangedRaisedOnAnotherThreadWhileAttaching_E /// The same defect reached re-entrantly rather than across threads: the property read the /// constructor performs for its initial emit itself raises /// , so the handler runs part-way through - /// construction on the subscribing thread. This also pins that the serialization is re-entrant, - /// since a non-re-entrant gate would deadlock here rather than fail. + /// construction on the subscribing thread. This also pins that a notification raised on the emitting + /// thread is handed to the running emit, since waiting for that emit would deadlock here rather than fail. /// /// A representing the asynchronous unit test. [Test] @@ -111,20 +114,20 @@ public async Task Subscribe_PropertyChangedRaisedReentrantlyDuringInitialRead_Em } /// - /// Pins the change's central claim - that a competing handler runs either wholly before or wholly - /// after the initial emit, never inside it - by holding a competing thread against the initial emit - /// while it is on the stack and recording whether its emit overlaps. + /// A competing handler never runs inside the initial emit and never waits for it. A thread that + /// writes while the initial emit is on the stack finishes at once, and the running emit delivers its + /// value after the initial one. Waiting on the observer for such a thread is therefore not a deadlock. /// /// A representing the asynchronous unit test. [Test] - public async Task Subscribe_PropertyChangedRaisedOnAnotherThreadDuringInitialEmit_DoesNotOverlapTheInitialEmit() + public async Task Subscribe_PropertyChangedRaisedOnAnotherThreadDuringInitialEmit_NeitherOverlapsNorBlocksThatThread() { var source = new HookedViewModel { Name = InitialName }; using var competitorStarted = new ManualResetEventSlim(false); Thread? competitor = null; + var competitorFinished = false; - // Runs from inside the downstream call of the initial emit, which is the window the change keeps - // exclusive. The bounded join is what a blocked competing thread looks like from in here. + // Runs from inside the downstream call of the initial emit, which is the window no other emit may enter. var recorder = new EmissionRecorder { OnFirstValue = () => @@ -137,7 +140,7 @@ public async Task Subscribe_PropertyChangedRaisedOnAnotherThreadDuringInitialEmi competitor.Start(); competitorStarted.Wait(); - _ = competitor.Join(InterleaveWindowMilliseconds); + competitorFinished = competitor.Join(CompetitorTimeoutMilliseconds); }, }; @@ -152,11 +155,74 @@ public async Task Subscribe_PropertyChangedRaisedOnAnotherThreadDuringInitialEmi competitor!.Join(); await AssertNoErrors(recorder); + await Assert.That(competitorFinished).IsTrue(); await Assert.That(recorder.MaxConcurrentEmissions).IsEqualTo(1); await AssertSequence(recorder.Snapshot(), InitialName, ReplacementName); } } + /// An observer that changes the property it observes gets the change after its own call returns. + /// A representing the asynchronous unit test. + [Test] + public async Task OnNext_ObserverChangesTheObservedProperty_DeliversTheChangeAfterTheObserverReturns() + { + var source = new HookedViewModel { Name = InitialName }; + var recorder = new EmissionRecorder { OnFirstValue = () => source.Name = ReplacementName }; + + var observable = new PropertyObservable( + source, + nameof(HookedViewModel.Name), + static x => ((HookedViewModel)x).Name, + distinctUntilChanged: true); + + using (observable.Subscribe(recorder)) + { + await AssertNoErrors(recorder); + await Assert.That(recorder.MaxConcurrentEmissions).IsEqualTo(1); + await AssertSequence(recorder.Snapshot(), InitialName, ReplacementName); + } + } + + /// + /// A read that throws reaches the thread that raised the change, and the subscription keeps working: + /// the next change still emits. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task OnPropertyChanged_ReadThrows_TheNextChangeStillEmits() + { + var source = new HookedViewModel { Name = InitialName }; + var recorder = new EmissionRecorder(); + var failNextRead = false; + + string? ReadOrFail(INotifyPropertyChanged instance) + { + if (!failNextRead) + { + return ((HookedViewModel)instance).Name; + } + + failNextRead = false; + throw new InvalidOperationException(nameof(ReadOrFail)); + } + + var observable = new PropertyObservable( + source, + nameof(HookedViewModel.Name), + ReadOrFail, + distinctUntilChanged: true); + + using (observable.Subscribe(recorder)) + { + failNextRead = true; + await Assert.That(() => source.Name = ReplacementName).ThrowsExactly(); + + source.Name = ThirdName; + + await AssertSequence(recorder.Snapshot(), InitialName, ThirdName); + } + } + /// /// The initial emit stays unconditional on an ordinary subscribe, including when the value equals /// the default for its type. The change applies the distinct-until-changed test to the initial emit From e0fcacb9bef198ac3deb349e99cbafb0020ea86c Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:33:54 +1000 Subject: [PATCH 4/5] ci: run every benchmark on each benchmark run - The benchmark workflow takes no suite or filter and runs all three benchmark projects in full. --- .github/workflows/benchmarks.yml | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index d6a5b2b6..ac642b42 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -2,20 +2,6 @@ name: Benchmarks on: workflow_dispatch: - inputs: - suite: - description: 'Which benchmark suite to run' - type: choice - default: runtime - options: - - runtime - - baseline - - generator - - all - filter: - description: 'BenchmarkDotNet filter, e.g. *WhenChanged* or *' - type: string - default: '*' permissions: contents: read @@ -24,12 +10,9 @@ jobs: benchmark: uses: reactiveui/actions-common/.github/workflows/workflow-common-benchmarks.yml@main with: - # One line per suite the choice covers. An unselected suite leaves a blank line behind, - # which the list skips. projects: | - ${{ (inputs.suite == 'runtime' || inputs.suite == 'all') && 'benchmarks/ReactiveUI.Binding.Benchmarks/ReactiveUI.Binding.Benchmarks.csproj' || '' }} - ${{ (inputs.suite == 'baseline' || inputs.suite == 'all') && 'benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ReactiveUI.Binding.Benchmarks.ReactiveUI.csproj' || '' }} - ${{ (inputs.suite == 'generator' || inputs.suite == 'all') && 'benchmarks/ReactiveUI.Binding.Generator.Benchmarks/ReactiveUI.Binding.Generator.Benchmarks.csproj' || '' }} - filter: ${{ inputs.filter }} + benchmarks/ReactiveUI.Binding.Benchmarks/ReactiveUI.Binding.Benchmarks.csproj + benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ReactiveUI.Binding.Benchmarks.ReactiveUI.csproj + benchmarks/ReactiveUI.Binding.Generator.Benchmarks/ReactiveUI.Binding.Generator.Benchmarks.csproj solutionFile: ReactiveUI.Binding.SourceGenerators.slnx installWorkloads: true From 61a4dcfef4ff5bb37e775b5f6f4cc6d5fc8e1316 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:18:37 +1000 Subject: [PATCH 5/5] test(benchmarks): share one config per platform and trace every benchmark - Every benchmark takes its jobs and profiler from a shared config: ETW with .NET Framework 4.6.2 on Windows, EventPipe CPU samples and GC events elsewhere. - No benchmark uses the memory diagnoser. - The view models, views and other test data live in Mocks folders, apart from the benchmark classes. - The generator benchmarks read compiled mock consumer source copied beside the assembly. - src/benchmarks/Directory.Build.props holds the settings the benchmark projects share. --- src/Directory.Packages.props | 1 + src/benchmarks/Directory.Build.props | 33 +++++ .../Mocks/BenchmarkModeDetector.cs | 16 +++ .../ModuleInitializer.cs | 41 ------- ...iveUI.Binding.Benchmarks.ReactiveUI.csproj | 17 +-- .../ReactiveUIBindingBenchmark.cs | 29 +++-- .../ReactiveUIObservationBenchmark.cs | 29 +++-- .../BindBenchmark.cs | 38 +----- .../BindOneWayBenchmark.cs | 18 +-- .../BindToBenchmark.cs | 18 +-- .../BindTwoWayBenchmark.cs | 18 +-- .../BindingInitializer.cs | 32 ----- .../InteractionBenchmark.cs | 17 +-- .../InvokeCommandBenchmark.cs | 18 +-- .../{ => Mocks}/BenchmarkChildViewModel.cs | 2 +- .../{ => Mocks}/BenchmarkCommand.cs | 2 +- .../{ => Mocks}/BenchmarkSource.cs | 2 +- .../{ => Mocks}/BenchmarkView.cs | 2 +- .../{ => Mocks}/BenchmarkViewModel.cs | 2 +- .../Mocks/CountingObserver.cs | 25 ++++ .../OneWayBindBenchmark.cs | 18 +-- .../ReactiveUI.Binding.Benchmarks.csproj | 10 -- .../RxUiDynamicChainBaseline.cs | 17 +-- .../UnsafeFallbackBenchmark.cs | 27 ++--- .../WhenAnyBenchmark.cs | 18 +-- .../WhenAnyDynamicBenchmark.cs | 16 +-- .../WhenAnyObservableBenchmark.cs | 18 +-- .../WhenAnyValueBenchmark.cs | 18 +-- .../WhenChangedBenchmark.cs | 18 +-- .../WhenChangedContentionBenchmark.cs | 18 +-- .../WhenChangingBenchmark.cs | 18 +-- ...ding.Generator.Benchmarks.Roslyn413.csproj | 7 +- .../GenerationBenchmarks.cs | 26 ++-- .../GeneratorCorpus.cs | 114 ------------------ .../Mocks/AddressViewModel.cs | 30 +++++ .../Mocks/PersonBindings.cs | 95 +++++++++++++++ .../Mocks/PersonView.cs | 62 ++++++++++ .../Mocks/PersonViewModel.cs | 71 +++++++++++ .../Mocks/SaveButton.cs | 19 +++ ...tiveUI.Binding.Generator.Benchmarks.csproj | 9 +- .../{ => Support}/GeneratorHarness.cs | 34 ++++-- .../Configs/NativeAotBenchmarkConfig.cs | 19 +++ .../Shared/Platforms/unix/BenchmarkConfig.cs | 22 ++++ .../Shared/Platforms/unix/ProfilerConfig.cs | 38 ++++++ .../Platforms/windows/BenchmarkConfig.cs | 23 ++++ .../Platforms/windows/ProfilerConfig.cs | 15 +++ 46 files changed, 602 insertions(+), 538 deletions(-) create mode 100644 src/benchmarks/Directory.Build.props create mode 100644 src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/Mocks/BenchmarkModeDetector.cs delete mode 100644 src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ModuleInitializer.cs delete mode 100644 src/benchmarks/ReactiveUI.Binding.Benchmarks/BindingInitializer.cs rename src/benchmarks/ReactiveUI.Binding.Benchmarks/{ => Mocks}/BenchmarkChildViewModel.cs (95%) rename src/benchmarks/ReactiveUI.Binding.Benchmarks/{ => Mocks}/BenchmarkCommand.cs (95%) rename src/benchmarks/ReactiveUI.Binding.Benchmarks/{ => Mocks}/BenchmarkSource.cs (97%) rename src/benchmarks/ReactiveUI.Binding.Benchmarks/{ => Mocks}/BenchmarkView.cs (97%) rename src/benchmarks/ReactiveUI.Binding.Benchmarks/{ => Mocks}/BenchmarkViewModel.cs (98%) create mode 100644 src/benchmarks/ReactiveUI.Binding.Benchmarks/Mocks/CountingObserver.cs delete mode 100644 src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/GeneratorCorpus.cs create mode 100644 src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Mocks/AddressViewModel.cs create mode 100644 src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Mocks/PersonBindings.cs create mode 100644 src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Mocks/PersonView.cs create mode 100644 src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Mocks/PersonViewModel.cs create mode 100644 src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Mocks/SaveButton.cs rename src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/{ => Support}/GeneratorHarness.cs (67%) create mode 100644 src/benchmarks/Shared/Configs/NativeAotBenchmarkConfig.cs create mode 100644 src/benchmarks/Shared/Platforms/unix/BenchmarkConfig.cs create mode 100644 src/benchmarks/Shared/Platforms/unix/ProfilerConfig.cs create mode 100644 src/benchmarks/Shared/Platforms/windows/BenchmarkConfig.cs create mode 100644 src/benchmarks/Shared/Platforms/windows/ProfilerConfig.cs diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 6c10c453..b0f74270 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -74,6 +74,7 @@ + diff --git a/src/benchmarks/Directory.Build.props b/src/benchmarks/Directory.Build.props new file mode 100644 index 00000000..27a62728 --- /dev/null +++ b/src/benchmarks/Directory.Build.props @@ -0,0 +1,33 @@ + + + + + + $([MSBuild]::NormalizeDirectory($(MSBuildProjectDirectory), '..')) + false + true + + + + Exe + false + + windows + unix + + + + + + + + + + + + + + + + + diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/Mocks/BenchmarkModeDetector.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/Mocks/BenchmarkModeDetector.cs new file mode 100644 index 00000000..2431bc19 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/Mocks/BenchmarkModeDetector.cs @@ -0,0 +1,16 @@ +// 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.Runtime.CompilerServices; +using Splat; + +namespace ReactiveUI.Binding.Benchmarks.Mocks; + +/// Tells ReactiveUI it is running outside an application, as a benchmark process does. +internal sealed class BenchmarkModeDetector : IModeDetector +{ + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool? InUnitTestRunner() => true; +} diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ModuleInitializer.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ModuleInitializer.cs deleted file mode 100644 index 005344a6..00000000 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ModuleInitializer.cs +++ /dev/null @@ -1,41 +0,0 @@ -// 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.Runtime.CompilerServices; -using ReactiveUI.Builder; -using Splat; - -namespace ReactiveUI.Binding.Benchmarks; - -/// -/// One-shot initializer that configures ReactiveUI before any benchmarks run. -/// Call from each benchmark class's static constructor. -/// -internal static class ModuleInitializer -{ - /// Guard flag to ensure initialization runs at most once. Uses . - private static int _initialized; - - /// Ensures ReactiveUI is initialized exactly once, regardless of how many benchmark classes call this method. - internal static void EnsureInitialized() - { - if (Interlocked.Exchange(ref _initialized, 1) != 0) - { - return; - } - - ModeDetector.OverrideModeDetector(new BenchmarkModeDetector()); - _ = RxAppBuilder.CreateReactiveUIBuilder() - .WithCoreServices() - .BuildApp(); - } - - /// Mode detector for benchmark context. - private sealed class BenchmarkModeDetector : IModeDetector - { - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool? InUnitTestRunner() => true; - } -} diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ReactiveUI.Binding.Benchmarks.ReactiveUI.csproj b/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ReactiveUI.Binding.Benchmarks.ReactiveUI.csproj index f3704786..c1f7c07f 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ReactiveUI.Binding.Benchmarks.ReactiveUI.csproj +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ReactiveUI.Binding.Benchmarks.ReactiveUI.csproj @@ -1,30 +1,19 @@ - Exe net8.0;net10.0;net11.0;net462 - - $(DefineConstants);BENCH_NETFX - enable - false false - - - - - + + + diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ReactiveUIBindingBenchmark.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ReactiveUIBindingBenchmark.cs index bdaf0b9b..142833b6 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ReactiveUIBindingBenchmark.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ReactiveUIBindingBenchmark.cs @@ -4,23 +4,15 @@ using System.Diagnostics; using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; +using ReactiveUI.Binding.Benchmarks.Configs; +using ReactiveUI.Binding.Benchmarks.Mocks; +using ReactiveUI.Builder; +using Splat; namespace ReactiveUI.Binding.Benchmarks; /// ReactiveUI expression-tree binding benchmarks for comparison. -#if BENCH_NETFX -[SimpleJob(RuntimeMoniker.Net462)] -#endif -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net10_0)] -[SimpleJob(RuntimeMoniker.Net11_0)] -[MemoryDiagnoser] -#if !BENCH_NETFX -[EventPipeProfiler(EventPipeProfile.GcVerbose)] -#endif -[MarkdownExporterAttribute.GitHub] +[Config(typeof(BenchmarkConfig))] [DebuggerDisplay("Expression-tree binding over {PropertyChangeCount} changes")] public class ReactiveUIBindingBenchmark { @@ -33,8 +25,15 @@ public class ReactiveUIBindingBenchmark /// The target view instance used for binding benchmarks. private BenchmarkView _target = null!; - /// Initializes static members of the class. Ensures ReactiveUI is configured before any benchmarks run. - static ReactiveUIBindingBenchmark() => ModuleInitializer.EnsureInitialized(); + /// Configures ReactiveUI for the benchmark process. + [GlobalSetup] + public static void Register() + { + ModeDetector.OverrideModeDetector(new BenchmarkModeDetector()); + _ = RxAppBuilder.CreateReactiveUIBuilder() + .WithCoreServices() + .BuildApp(); + } /// Sets up fresh source and target objects before each benchmark iteration. [IterationSetup] diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ReactiveUIObservationBenchmark.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ReactiveUIObservationBenchmark.cs index 0bc96add..98f2f5f5 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ReactiveUIObservationBenchmark.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ReactiveUIObservationBenchmark.cs @@ -4,23 +4,15 @@ using System.Diagnostics; using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; +using ReactiveUI.Binding.Benchmarks.Configs; +using ReactiveUI.Binding.Benchmarks.Mocks; +using ReactiveUI.Builder; +using Splat; namespace ReactiveUI.Binding.Benchmarks; /// ReactiveUI expression-tree WhenAnyValue benchmarks for comparison. -#if BENCH_NETFX -[SimpleJob(RuntimeMoniker.Net462)] -#endif -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net10_0)] -[SimpleJob(RuntimeMoniker.Net11_0)] -[MemoryDiagnoser] -#if !BENCH_NETFX -[EventPipeProfiler(EventPipeProfile.GcVerbose)] -#endif -[MarkdownExporterAttribute.GitHub] +[Config(typeof(BenchmarkConfig))] [DebuggerDisplay("Expression-tree observation over {PropertyChangeCount} changes")] public class ReactiveUIObservationBenchmark { @@ -30,8 +22,15 @@ public class ReactiveUIObservationBenchmark /// The view model instance used for observation benchmarks. private BenchmarkViewModel _vm = null!; - /// Initializes static members of the class. Ensures ReactiveUI is configured before any benchmarks run. - static ReactiveUIObservationBenchmark() => ModuleInitializer.EnsureInitialized(); + /// Configures ReactiveUI for the benchmark process. + [GlobalSetup] + public static void Register() + { + ModeDetector.OverrideModeDetector(new BenchmarkModeDetector()); + _ = RxAppBuilder.CreateReactiveUIBuilder() + .WithCoreServices() + .BuildApp(); + } /// Sets up a fresh view model before each benchmark iteration. [IterationSetup] diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/BindBenchmark.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/BindBenchmark.cs index 6b299ce4..221f0764 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/BindBenchmark.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/BindBenchmark.cs @@ -3,8 +3,8 @@ // See the LICENSE file in the project root for full license information. using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; +using ReactiveUI.Binding.Benchmarks.Configs; +using ReactiveUI.Binding.Benchmarks.Mocks; namespace ReactiveUI.Binding.Benchmarks; @@ -13,19 +13,7 @@ namespace ReactiveUI.Binding.Benchmarks; /// and applied once, and the changes it wrote are published to whoever subscribes to the binding - so the cost /// of making one, of driving it from either side, and of watching what it did are all measured here. /// -#if BENCH_NETFX -[SimpleJob(RuntimeMoniker.Net462)] -#endif -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net10_0)] -[SimpleJob(RuntimeMoniker.Net11_0)] -[SimpleJob(RuntimeMoniker.NativeAot10_0, id: nameof(RuntimeMoniker.NativeAot10_0))] -[SimpleJob(RuntimeMoniker.NativeAot11_0, id: nameof(RuntimeMoniker.NativeAot11_0))] -[MemoryDiagnoser] -#if !BENCH_NETFX -[EventPipeProfiler(EventPipeProfile.GcVerbose)] -#endif -[MarkdownExporterAttribute.GitHub] +[Config(typeof(NativeAotBenchmarkConfig))] public class BindBenchmark { /// Represents the number of property change events to be triggered during the benchmark tests. @@ -94,24 +82,4 @@ public void WithObservedChanges() _viewModel.Name = $"Name_{i}"; } } - - /// Counts what a binding reported without allocating per change. - private sealed class CountingObserver : IObserver - { - /// Gets how many changes were reported. - public int Count { get; private set; } - - /// - public void OnNext(BindingChange value) => Count++; - - /// - public void OnError(Exception error) - { - } - - /// - public void OnCompleted() - { - } - } } diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/BindOneWayBenchmark.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/BindOneWayBenchmark.cs index 522ff139..433372a2 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/BindOneWayBenchmark.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/BindOneWayBenchmark.cs @@ -3,26 +3,14 @@ // See the LICENSE file in the project root for full license information. using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; +using ReactiveUI.Binding.Benchmarks.Configs; +using ReactiveUI.Binding.Benchmarks.Mocks; using ReactiveUI.Primitives.Concurrency; namespace ReactiveUI.Binding.Benchmarks; /// Source-generated BindOneWay benchmarks with and without scheduler. -#if BENCH_NETFX -[SimpleJob(RuntimeMoniker.Net462)] -#endif -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net10_0)] -[SimpleJob(RuntimeMoniker.Net11_0)] -[SimpleJob(RuntimeMoniker.NativeAot10_0, id: nameof(RuntimeMoniker.NativeAot10_0))] -[SimpleJob(RuntimeMoniker.NativeAot11_0, id: nameof(RuntimeMoniker.NativeAot11_0))] -[MemoryDiagnoser] -#if !BENCH_NETFX -[EventPipeProfiler(EventPipeProfile.GcVerbose)] -#endif -[MarkdownExporterAttribute.GitHub] +[Config(typeof(NativeAotBenchmarkConfig))] public class BindOneWayBenchmark { /// Represents the number of property change events to be triggered during the benchmark tests. diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/BindToBenchmark.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/BindToBenchmark.cs index 9091e4bd..448c5303 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/BindToBenchmark.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/BindToBenchmark.cs @@ -3,25 +3,13 @@ // See the LICENSE file in the project root for full license information. using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; +using ReactiveUI.Binding.Benchmarks.Configs; +using ReactiveUI.Binding.Benchmarks.Mocks; namespace ReactiveUI.Binding.Benchmarks; /// Source-generated BindTo benchmarks, which write a stream's values into a target property. -#if BENCH_NETFX -[SimpleJob(RuntimeMoniker.Net462)] -#endif -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net10_0)] -[SimpleJob(RuntimeMoniker.Net11_0)] -[SimpleJob(RuntimeMoniker.NativeAot10_0, id: nameof(RuntimeMoniker.NativeAot10_0))] -[SimpleJob(RuntimeMoniker.NativeAot11_0, id: nameof(RuntimeMoniker.NativeAot11_0))] -[MemoryDiagnoser] -#if !BENCH_NETFX -[EventPipeProfiler(EventPipeProfile.GcVerbose)] -#endif -[MarkdownExporterAttribute.GitHub] +[Config(typeof(NativeAotBenchmarkConfig))] public class BindToBenchmark { /// How many values each benchmark pushes through one binding. diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/BindTwoWayBenchmark.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/BindTwoWayBenchmark.cs index 4dd20a45..db5ae204 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/BindTwoWayBenchmark.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/BindTwoWayBenchmark.cs @@ -4,26 +4,14 @@ using System.Runtime.CompilerServices; using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; +using ReactiveUI.Binding.Benchmarks.Configs; +using ReactiveUI.Binding.Benchmarks.Mocks; using ReactiveUI.Primitives.Concurrency; namespace ReactiveUI.Binding.Benchmarks; /// Source-generated BindTwoWay benchmarks with and without scheduler. -#if BENCH_NETFX -[SimpleJob(RuntimeMoniker.Net462)] -#endif -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net10_0)] -[SimpleJob(RuntimeMoniker.Net11_0)] -[SimpleJob(RuntimeMoniker.NativeAot10_0, id: nameof(RuntimeMoniker.NativeAot10_0))] -[SimpleJob(RuntimeMoniker.NativeAot11_0, id: nameof(RuntimeMoniker.NativeAot11_0))] -[MemoryDiagnoser] -#if !BENCH_NETFX -[EventPipeProfiler(EventPipeProfile.GcVerbose)] -#endif -[MarkdownExporterAttribute.GitHub] +[Config(typeof(NativeAotBenchmarkConfig))] public class BindTwoWayBenchmark { /// Represents the number of property change events to be triggered during the benchmark tests. diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/BindingInitializer.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/BindingInitializer.cs deleted file mode 100644 index 001ebe8b..00000000 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/BindingInitializer.cs +++ /dev/null @@ -1,32 +0,0 @@ -// 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 ReactiveUI.Binding.Builder; -using ReactiveUI.Binding.Mixins; - -namespace ReactiveUI.Binding.Benchmarks; - -/// Builds the runtime services once per process, for the benchmarks that resolve through them. -/// -/// The generated path needs none of this: it names every type and member it touches. Only the reflection -/// fallback resolves through the registered services, so only the benchmarks measuring it initialise them. -/// -internal static class BindingInitializer -{ - /// Guards the one-shot build. - private static int _initialized; - - /// Builds the core services, at most once however many benchmark classes ask. - internal static void EnsureInitialized() - { - if (Interlocked.Exchange(ref _initialized, 1) != 0) - { - return; - } - - _ = RxBindingBuilder.CreateReactiveUIBindingBuilder() - .WithCoreServices() - .BuildApp(); - } -} diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/InteractionBenchmark.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/InteractionBenchmark.cs index 0613ef6a..8ce85416 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/InteractionBenchmark.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/InteractionBenchmark.cs @@ -3,25 +3,12 @@ // See the LICENSE file in the project root for full license information. using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; +using ReactiveUI.Binding.Benchmarks.Configs; namespace ReactiveUI.Binding.Benchmarks; /// Interaction benchmarks: asking a question, and registering and removing handlers. -#if BENCH_NETFX -[SimpleJob(RuntimeMoniker.Net462)] -#endif -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net10_0)] -[SimpleJob(RuntimeMoniker.Net11_0)] -[SimpleJob(RuntimeMoniker.NativeAot10_0, id: nameof(RuntimeMoniker.NativeAot10_0))] -[SimpleJob(RuntimeMoniker.NativeAot11_0, id: nameof(RuntimeMoniker.NativeAot11_0))] -[MemoryDiagnoser] -#if !BENCH_NETFX -[EventPipeProfiler(EventPipeProfile.GcVerbose)] -#endif -[MarkdownExporterAttribute.GitHub] +[Config(typeof(NativeAotBenchmarkConfig))] public class InteractionBenchmark { /// How many questions each Handle benchmark asks. diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/InvokeCommandBenchmark.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/InvokeCommandBenchmark.cs index 4a6b3ebb..b2835cb1 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/InvokeCommandBenchmark.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/InvokeCommandBenchmark.cs @@ -3,25 +3,13 @@ // See the LICENSE file in the project root for full license information. using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; +using ReactiveUI.Binding.Benchmarks.Configs; +using ReactiveUI.Binding.Benchmarks.Mocks; namespace ReactiveUI.Binding.Benchmarks; /// Source-generated InvokeCommand benchmarks, which run a command with each value a stream produces. -#if BENCH_NETFX -[SimpleJob(RuntimeMoniker.Net462)] -#endif -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net10_0)] -[SimpleJob(RuntimeMoniker.Net11_0)] -[SimpleJob(RuntimeMoniker.NativeAot10_0, id: nameof(RuntimeMoniker.NativeAot10_0))] -[SimpleJob(RuntimeMoniker.NativeAot11_0, id: nameof(RuntimeMoniker.NativeAot11_0))] -[MemoryDiagnoser] -#if !BENCH_NETFX -[EventPipeProfiler(EventPipeProfile.GcVerbose)] -#endif -[MarkdownExporterAttribute.GitHub] +[Config(typeof(NativeAotBenchmarkConfig))] public class InvokeCommandBenchmark { /// How many values each benchmark pushes through one invocation. diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/BenchmarkChildViewModel.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/Mocks/BenchmarkChildViewModel.cs similarity index 95% rename from src/benchmarks/ReactiveUI.Binding.Benchmarks/BenchmarkChildViewModel.cs rename to src/benchmarks/ReactiveUI.Binding.Benchmarks/Mocks/BenchmarkChildViewModel.cs index 09e53360..8cbaff25 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/BenchmarkChildViewModel.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/Mocks/BenchmarkChildViewModel.cs @@ -5,7 +5,7 @@ using System.ComponentModel; using System.Diagnostics; -namespace ReactiveUI.Binding.Benchmarks; +namespace ReactiveUI.Binding.Benchmarks.Mocks; /// A child view model for deep chain benchmarks. [DebuggerDisplay("Value = {Value}")] diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/BenchmarkCommand.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/Mocks/BenchmarkCommand.cs similarity index 95% rename from src/benchmarks/ReactiveUI.Binding.Benchmarks/BenchmarkCommand.cs rename to src/benchmarks/ReactiveUI.Binding.Benchmarks/Mocks/BenchmarkCommand.cs index 45eb359e..66d2b58e 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/BenchmarkCommand.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/Mocks/BenchmarkCommand.cs @@ -5,7 +5,7 @@ using System.Runtime.CompilerServices; using System.Windows.Input; -namespace ReactiveUI.Binding.Benchmarks; +namespace ReactiveUI.Binding.Benchmarks.Mocks; /// A command that counts what reached it, so an invocation benchmark measures the wiring and not the body. public sealed class BenchmarkCommand : ICommand diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/BenchmarkSource.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/Mocks/BenchmarkSource.cs similarity index 97% rename from src/benchmarks/ReactiveUI.Binding.Benchmarks/BenchmarkSource.cs rename to src/benchmarks/ReactiveUI.Binding.Benchmarks/Mocks/BenchmarkSource.cs index a4c0b2cc..c3efa63d 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/BenchmarkSource.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/Mocks/BenchmarkSource.cs @@ -4,7 +4,7 @@ using System.Runtime.CompilerServices; -namespace ReactiveUI.Binding.Benchmarks; +namespace ReactiveUI.Binding.Benchmarks.Mocks; /// A stream the benchmark drives by hand, so the value count is the measurement. /// The value type. diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/BenchmarkView.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/Mocks/BenchmarkView.cs similarity index 97% rename from src/benchmarks/ReactiveUI.Binding.Benchmarks/BenchmarkView.cs rename to src/benchmarks/ReactiveUI.Binding.Benchmarks/Mocks/BenchmarkView.cs index b3fe470b..4b8a44f1 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/BenchmarkView.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/Mocks/BenchmarkView.cs @@ -5,7 +5,7 @@ using System.ComponentModel; using System.Diagnostics; -namespace ReactiveUI.Binding.Benchmarks; +namespace ReactiveUI.Binding.Benchmarks.Mocks; /// A view used for binding benchmarks. Implements to support ReactiveUI's expression-tree-based binding APIs. [DebuggerDisplay("DisplayName = {DisplayName}, DisplayAge = {DisplayAge}")] diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/BenchmarkViewModel.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/Mocks/BenchmarkViewModel.cs similarity index 98% rename from src/benchmarks/ReactiveUI.Binding.Benchmarks/BenchmarkViewModel.cs rename to src/benchmarks/ReactiveUI.Binding.Benchmarks/Mocks/BenchmarkViewModel.cs index 5b988f85..efacfc28 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/BenchmarkViewModel.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/Mocks/BenchmarkViewModel.cs @@ -6,7 +6,7 @@ using System.Diagnostics; using System.Windows.Input; -namespace ReactiveUI.Binding.Benchmarks; +namespace ReactiveUI.Binding.Benchmarks.Mocks; /// A view model used for benchmarking source-generated property observation and binding. [DebuggerDisplay("Name = {Name}, Age = {Age}")] diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/Mocks/CountingObserver.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/Mocks/CountingObserver.cs new file mode 100644 index 00000000..f88df964 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/Mocks/CountingObserver.cs @@ -0,0 +1,25 @@ +// 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. + +namespace ReactiveUI.Binding.Benchmarks.Mocks; + +/// Counts what a binding reported without allocating per change. +internal sealed class CountingObserver : IObserver +{ + /// Gets how many changes were reported. + public int Count { get; private set; } + + /// + public void OnNext(BindingChange value) => Count++; + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } +} diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/OneWayBindBenchmark.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/OneWayBindBenchmark.cs index fb77925c..048ada79 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/OneWayBindBenchmark.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/OneWayBindBenchmark.cs @@ -3,25 +3,13 @@ // See the LICENSE file in the project root for full license information. using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; +using ReactiveUI.Binding.Benchmarks.Configs; +using ReactiveUI.Binding.Benchmarks.Mocks; namespace ReactiveUI.Binding.Benchmarks; /// Source-generated OneWayBind benchmarks, which read against the ReactiveUI baseline of the same name. -#if BENCH_NETFX -[SimpleJob(RuntimeMoniker.Net462)] -#endif -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net10_0)] -[SimpleJob(RuntimeMoniker.Net11_0)] -[SimpleJob(RuntimeMoniker.NativeAot10_0, id: nameof(RuntimeMoniker.NativeAot10_0))] -[SimpleJob(RuntimeMoniker.NativeAot11_0, id: nameof(RuntimeMoniker.NativeAot11_0))] -[MemoryDiagnoser] -#if !BENCH_NETFX -[EventPipeProfiler(EventPipeProfile.GcVerbose)] -#endif -[MarkdownExporterAttribute.GitHub] +[Config(typeof(NativeAotBenchmarkConfig))] public class OneWayBindBenchmark { /// How many property changes each benchmark drives through one binding. diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/ReactiveUI.Binding.Benchmarks.csproj b/src/benchmarks/ReactiveUI.Binding.Benchmarks/ReactiveUI.Binding.Benchmarks.csproj index 863db79a..fe90a2e2 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/ReactiveUI.Binding.Benchmarks.csproj +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/ReactiveUI.Binding.Benchmarks.csproj @@ -1,21 +1,11 @@ - Exe net8.0;net10.0;net11.0;net462 - - $(DefineConstants);BENCH_NETFX - enable - false true - diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/RxUiDynamicChainBaseline.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/RxUiDynamicChainBaseline.cs index ab58df93..d5a8786f 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/RxUiDynamicChainBaseline.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/RxUiDynamicChainBaseline.cs @@ -8,28 +8,17 @@ using System.Linq.Expressions; using System.Runtime.CompilerServices; using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; using ReactiveUI; +using ReactiveUI.Binding.Benchmarks.Configs; using ReactiveUI.Builder; -using BenchmarkVm = ReactiveUI.Binding.Benchmarks.BenchmarkViewModel; +using BenchmarkVm = ReactiveUI.Binding.Benchmarks.Mocks.BenchmarkViewModel; // Outside ReactiveUI.Binding so extension lookup reaches ReactiveUI's overloads; the view model comes in by alias, // since importing its namespace would make every call ambiguous. namespace RxUiDynamicChain; /// The dynamic-chain scenarios of WhenAnyDynamicBenchmark, run against ReactiveUI's own engine. -#if BENCH_NETFX -[SimpleJob(RuntimeMoniker.Net462)] -#endif -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net10_0)] -[SimpleJob(RuntimeMoniker.Net11_0)] -[MemoryDiagnoser] -#if !BENCH_NETFX -[EventPipeProfiler(EventPipeProfile.GcVerbose)] -#endif -[MarkdownExporterAttribute.GitHub] +[Config(typeof(BenchmarkConfig))] #if NET8_0_OR_GREATER [RequiresUnreferencedCode("Evaluates expression-based member chains via reflection; members may be trimmed.")] #endif diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/UnsafeFallbackBenchmark.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/UnsafeFallbackBenchmark.cs index 0413c917..e106886b 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/UnsafeFallbackBenchmark.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/UnsafeFallbackBenchmark.cs @@ -6,8 +6,9 @@ using System.Diagnostics.CodeAnalysis; #endif using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; +using ReactiveUI.Binding.Benchmarks.Configs; +using ReactiveUI.Binding.Benchmarks.Mocks; +using ReactiveUI.Binding.Builder; namespace ReactiveUI.Binding.Benchmarks; @@ -17,17 +18,7 @@ namespace ReactiveUI.Binding.Benchmarks; /// walk the path at run time, so an ahead-of-time publish cannot be relied on to keep the members they reach. /// Read these against the generated benchmark of the same operator to see what the fallback costs. /// -#if BENCH_NETFX -[SimpleJob(RuntimeMoniker.Net462)] -#endif -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net10_0)] -[SimpleJob(RuntimeMoniker.Net11_0)] -[MemoryDiagnoser] -#if !BENCH_NETFX -[EventPipeProfiler(EventPipeProfile.GcVerbose)] -#endif -[MarkdownExporterAttribute.GitHub] +[Config(typeof(BenchmarkConfig))] #if NET8_0_OR_GREATER [RequiresUnreferencedCode("Evaluates expression-based member chains via reflection; members may be trimmed.")] #endif @@ -42,8 +33,14 @@ public class UnsafeFallbackBenchmark /// The binding's target. private BenchmarkView _view = null!; - /// Builds the runtime services the reflection path resolves through, once for the process. - static UnsafeFallbackBenchmark() => BindingInitializer.EnsureInitialized(); + /// Registers the runtime services the reflection path resolves through. + [GlobalSetup] + public static void Register() + { + var builder = RxBindingBuilder.CreateReactiveUIBindingBuilder(); + _ = builder.WithCoreServices(); + _ = builder.BuildApp(); + } /// Builds a fresh source and target before each iteration. [IterationSetup] diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenAnyBenchmark.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenAnyBenchmark.cs index 7e244d9e..1a3223a8 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenAnyBenchmark.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenAnyBenchmark.cs @@ -3,25 +3,13 @@ // See the LICENSE file in the project root for full license information. using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; +using ReactiveUI.Binding.Benchmarks.Configs; +using ReactiveUI.Binding.Benchmarks.Mocks; namespace ReactiveUI.Binding.Benchmarks; /// Source-generated WhenAny benchmarks, which hand each change to a selector as an observed change. -#if BENCH_NETFX -[SimpleJob(RuntimeMoniker.Net462)] -#endif -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net10_0)] -[SimpleJob(RuntimeMoniker.Net11_0)] -[SimpleJob(RuntimeMoniker.NativeAot10_0, id: nameof(RuntimeMoniker.NativeAot10_0))] -[SimpleJob(RuntimeMoniker.NativeAot11_0, id: nameof(RuntimeMoniker.NativeAot11_0))] -[MemoryDiagnoser] -#if !BENCH_NETFX -[EventPipeProfiler(EventPipeProfile.GcVerbose)] -#endif -[MarkdownExporterAttribute.GitHub] +[Config(typeof(NativeAotBenchmarkConfig))] public class WhenAnyBenchmark { /// How many property changes each benchmark drives through one subscription. diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenAnyDynamicBenchmark.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenAnyDynamicBenchmark.cs index 4238320b..732ad126 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenAnyDynamicBenchmark.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenAnyDynamicBenchmark.cs @@ -8,24 +8,14 @@ using System.Linq.Expressions; using System.Runtime.CompilerServices; using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; +using ReactiveUI.Binding.Benchmarks.Configs; +using ReactiveUI.Binding.Benchmarks.Mocks; using ReactiveUI.Binding.Builder; namespace ReactiveUI.Binding.Benchmarks; /// Benchmarks reflection-walked observation against the generated observation of the same chain. -#if BENCH_NETFX -[SimpleJob(RuntimeMoniker.Net462)] -#endif -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net10_0)] -[SimpleJob(RuntimeMoniker.Net11_0)] -[MemoryDiagnoser] -#if !BENCH_NETFX -[EventPipeProfiler(EventPipeProfile.GcVerbose)] -#endif -[MarkdownExporterAttribute.GitHub] +[Config(typeof(BenchmarkConfig))] #if NET8_0_OR_GREATER [RequiresUnreferencedCode("Evaluates expression-based member chains via reflection; members may be trimmed.")] #endif diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenAnyObservableBenchmark.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenAnyObservableBenchmark.cs index 2c79ce27..aaf35bcc 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenAnyObservableBenchmark.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenAnyObservableBenchmark.cs @@ -3,25 +3,13 @@ // See the LICENSE file in the project root for full license information. using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; +using ReactiveUI.Binding.Benchmarks.Configs; +using ReactiveUI.Binding.Benchmarks.Mocks; namespace ReactiveUI.Binding.Benchmarks; /// Source-generated WhenAnyObservable benchmarks, which switch to whichever stream a property holds. -#if BENCH_NETFX -[SimpleJob(RuntimeMoniker.Net462)] -#endif -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net10_0)] -[SimpleJob(RuntimeMoniker.Net11_0)] -[SimpleJob(RuntimeMoniker.NativeAot10_0, id: nameof(RuntimeMoniker.NativeAot10_0))] -[SimpleJob(RuntimeMoniker.NativeAot11_0, id: nameof(RuntimeMoniker.NativeAot11_0))] -[MemoryDiagnoser] -#if !BENCH_NETFX -[EventPipeProfiler(EventPipeProfile.GcVerbose)] -#endif -[MarkdownExporterAttribute.GitHub] +[Config(typeof(NativeAotBenchmarkConfig))] public class WhenAnyObservableBenchmark { /// How many values each benchmark pushes through the observed stream. diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenAnyValueBenchmark.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenAnyValueBenchmark.cs index 28aa2e35..8fc03904 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenAnyValueBenchmark.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenAnyValueBenchmark.cs @@ -3,25 +3,13 @@ // See the LICENSE file in the project root for full license information. using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; +using ReactiveUI.Binding.Benchmarks.Configs; +using ReactiveUI.Binding.Benchmarks.Mocks; namespace ReactiveUI.Binding.Benchmarks; /// Source-generated WhenAnyValue benchmarks, which read against the ReactiveUI baseline of the same name. -#if BENCH_NETFX -[SimpleJob(RuntimeMoniker.Net462)] -#endif -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net10_0)] -[SimpleJob(RuntimeMoniker.Net11_0)] -[SimpleJob(RuntimeMoniker.NativeAot10_0, id: nameof(RuntimeMoniker.NativeAot10_0))] -[SimpleJob(RuntimeMoniker.NativeAot11_0, id: nameof(RuntimeMoniker.NativeAot11_0))] -[MemoryDiagnoser] -#if !BENCH_NETFX -[EventPipeProfiler(EventPipeProfile.GcVerbose)] -#endif -[MarkdownExporterAttribute.GitHub] +[Config(typeof(NativeAotBenchmarkConfig))] public class WhenAnyValueBenchmark { /// How many property changes each benchmark drives through one subscription. diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenChangedBenchmark.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenChangedBenchmark.cs index fe8a59ee..59c03282 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenChangedBenchmark.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenChangedBenchmark.cs @@ -3,25 +3,13 @@ // See the LICENSE file in the project root for full license information. using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; +using ReactiveUI.Binding.Benchmarks.Configs; +using ReactiveUI.Binding.Benchmarks.Mocks; namespace ReactiveUI.Binding.Benchmarks; /// Source-generated WhenChanged benchmarks using lightweight observables. -#if BENCH_NETFX -[SimpleJob(RuntimeMoniker.Net462)] -#endif -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net10_0)] -[SimpleJob(RuntimeMoniker.Net11_0)] -[SimpleJob(RuntimeMoniker.NativeAot10_0, id: nameof(RuntimeMoniker.NativeAot10_0))] -[SimpleJob(RuntimeMoniker.NativeAot11_0, id: nameof(RuntimeMoniker.NativeAot11_0))] -[MemoryDiagnoser] -#if !BENCH_NETFX -[EventPipeProfiler(EventPipeProfile.GcVerbose)] -#endif -[MarkdownExporterAttribute.GitHub] +[Config(typeof(NativeAotBenchmarkConfig))] public class WhenChangedBenchmark { /// Represents the number of property change events to be triggered during the benchmark tests. diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenChangedContentionBenchmark.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenChangedContentionBenchmark.cs index cf4f557e..6cd4a2b6 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenChangedContentionBenchmark.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenChangedContentionBenchmark.cs @@ -3,25 +3,13 @@ // See the LICENSE file in the project root for full license information. using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; +using ReactiveUI.Binding.Benchmarks.Configs; +using ReactiveUI.Binding.Benchmarks.Mocks; namespace ReactiveUI.Binding.Benchmarks; /// WhenChanged with two threads changing the observed property at the same time. -#if BENCH_NETFX -[SimpleJob(RuntimeMoniker.Net462)] -#endif -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net10_0)] -[SimpleJob(RuntimeMoniker.Net11_0)] -[SimpleJob(RuntimeMoniker.NativeAot10_0, id: nameof(RuntimeMoniker.NativeAot10_0))] -[SimpleJob(RuntimeMoniker.NativeAot11_0, id: nameof(RuntimeMoniker.NativeAot11_0))] -[MemoryDiagnoser] -#if !BENCH_NETFX -[EventPipeProfiler(EventPipeProfile.GcVerbose)] -#endif -[MarkdownExporterAttribute.GitHub] +[Config(typeof(NativeAotBenchmarkConfig))] public class WhenChangedContentionBenchmark { /// How many property changes each writer drives. diff --git a/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenChangingBenchmark.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenChangingBenchmark.cs index 6313741f..081d0f8f 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenChangingBenchmark.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenChangingBenchmark.cs @@ -3,25 +3,13 @@ // See the LICENSE file in the project root for full license information. using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; +using ReactiveUI.Binding.Benchmarks.Configs; +using ReactiveUI.Binding.Benchmarks.Mocks; namespace ReactiveUI.Binding.Benchmarks; /// Source-generated WhenChanging benchmarks, which observe before the value is replaced. -#if BENCH_NETFX -[SimpleJob(RuntimeMoniker.Net462)] -#endif -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net10_0)] -[SimpleJob(RuntimeMoniker.Net11_0)] -[SimpleJob(RuntimeMoniker.NativeAot10_0, id: nameof(RuntimeMoniker.NativeAot10_0))] -[SimpleJob(RuntimeMoniker.NativeAot11_0, id: nameof(RuntimeMoniker.NativeAot11_0))] -[MemoryDiagnoser] -#if !BENCH_NETFX -[EventPipeProfiler(EventPipeProfile.GcVerbose)] -#endif -[MarkdownExporterAttribute.GitHub] +[Config(typeof(NativeAotBenchmarkConfig))] public class WhenChangingBenchmark { /// How many property changes each benchmark drives through one subscription. diff --git a/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks.Roslyn413/ReactiveUI.Binding.Generator.Benchmarks.Roslyn413.csproj b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks.Roslyn413/ReactiveUI.Binding.Generator.Benchmarks.Roslyn413.csproj index b59b477f..9fc2e56a 100644 --- a/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks.Roslyn413/ReactiveUI.Binding.Generator.Benchmarks.Roslyn413.csproj +++ b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks.Roslyn413/ReactiveUI.Binding.Generator.Benchmarks.Roslyn413.csproj @@ -3,16 +3,12 @@ - Exe net8.0;net10.0;net11.0 - enable - false ReactiveUI.Binding.Generator.Benchmarks.Roslyn413 ReactiveUI.Binding.Generator.Benchmarks - @@ -27,6 +23,9 @@ + diff --git a/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/GenerationBenchmarks.cs b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/GenerationBenchmarks.cs index 92e87a2a..b6dd317d 100644 --- a/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/GenerationBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/GenerationBenchmarks.cs @@ -3,30 +3,24 @@ // See the LICENSE file in the project root for full license information. using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; using Microsoft.CodeAnalysis; +using ReactiveUI.Binding.Benchmarks.Configs; +using ReactiveUI.Binding.Generator.Benchmarks.Support; namespace ReactiveUI.Binding.Generator.Benchmarks; -/// Measures a full generation pass over a corpus of consumer code. +/// Measures a full generation pass over the mock consumer source. /// /// The driver is rebuilt per iteration so each measurement is a cold generation, which is what a consumer's /// build actually pays. Reusing a primed driver would measure the incremental cache instead, and hoisting the /// driver into setup would let one iteration's caches serve the next. /// -[MemoryDiagnoser] -#if !BENCH_NETFX -[EventPipeProfiler(EventPipeProfile.GcVerbose)] -#endif +[Config(typeof(ProfilerConfig))] public class GenerationBenchmarks { - /// The corpus compilation, built once per parameter set. + /// The mock consumer compilation, built once per parameter set. private Compilation _compilation = null!; - /// Gets or sets how many view-model and view pairs the corpus holds. - [Params(1, 16, 64)] - public int Pairs { get; set; } - /// /// Gets or sets a value indicating whether the build lists the generated namespace, which is what decides /// between claiming each call site outright and offering an overload that competes for them all. @@ -35,16 +29,16 @@ public class GenerationBenchmarks public bool Intercept { get; set; } /// - /// Builds the corpus compilation once per parameter set. Loading a framework's worth of metadata + /// Builds the mock consumer compilation once per parameter set. Loading a framework's worth of metadata /// references costs far more than a generation pass and is work the host build does once, so measuring it /// per iteration would bury what this benchmark is for. /// [GlobalSetup] - public void Setup() => _compilation = GeneratorHarness.BuildCompilation(GeneratorCorpus.Build(Pairs), Intercept); + public void Setup() => _compilation = GeneratorHarness.BuildCompilation(Intercept); /// Runs a whole cold generation: syntax scan, extraction, and emission. /// The number of generated characters, returned so the work cannot be optimized away. - /// The corpus generated nothing, so there is no result to report. + /// The mock consumer source generated nothing, so there is no result to report. [Benchmark] public int Generate() { @@ -59,10 +53,10 @@ public int Generate() characters += generated.SourceText.Length; } - // A corpus that stopped matching the APIs would generate nothing and quietly turn this into a + // Mock source that stopped matching the APIs would generate nothing and quietly turn this into a // measurement of driver overhead, so refuse to report a number for it. return characters == 0 - ? throw new InvalidOperationException("The corpus generated no source; the benchmark is measuring nothing.") + ? throw new InvalidOperationException("The mock consumer source generated nothing; the benchmark is measuring nothing.") : characters; } } diff --git a/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/GeneratorCorpus.cs b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/GeneratorCorpus.cs deleted file mode 100644 index c773fba6..00000000 --- a/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/GeneratorCorpus.cs +++ /dev/null @@ -1,114 +0,0 @@ -// 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.Runtime.CompilerServices; -using System.Text; - -namespace ReactiveUI.Binding.Generator.Benchmarks; - -/// -/// Builds consumer source for the generator to chew on: view-model and view pairs with a spread of call sites -/// across the observation and binding APIs. -/// -/// -/// A corpus rather than one call site, because the emitter's cost is per invocation and per group; a single -/// call site measures mostly driver overhead and would hide whatever the emitter itself does. -/// -internal static class GeneratorCorpus -{ - /// Opens the body of a corpus type. - private const string TypeBodyOpen = " {"; - - /// Closes the body of a corpus type. - private const string TypeBodyClose = " }"; - - /// Names the view model parameter and opens the view parameter of a corpus call site. - private const string ViewModelAndViewParameters = " vm, MyView"; - - /// Roughly how many characters one view-model and view pair contributes. - private const int PairSourceCapacity = 2_048; - - /// Builds a compilation unit containing the given number of view-model and view pairs. - /// How many view-model and view pairs to emit. - /// The source text. - internal static string Build(int pairCount) - { - var sb = new StringBuilder(pairCount * PairSourceCapacity); - - _ = sb.AppendLine("using System;") - .AppendLine("using System.ComponentModel;") - .AppendLine("using System.Windows.Input;") - .AppendLine("using ReactiveUI.Binding;") - .AppendLine() - .AppendLine("namespace Corpus") - .AppendLine("{"); - - for (var i = 0; i < pairCount; i++) - { - AppendPair(sb, i); - } - - return sb.AppendLine("}").ToString(); - } - - /// Appends one view-model, view, and usage class. - /// The builder to append to. - /// The index that makes the emitted names unique. - private static void AppendPair(StringBuilder sb, int index) - { - AppendTypes(sb, index); - AppendUsage(sb, index); - } - - /// Appends the view model, child, button, and view for one pair. - /// The builder to append to. - /// The index that makes the emitted names unique. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void AppendTypes(StringBuilder sb, int index) => - sb.Append(" public class Child").Append(index).AppendLine(" : INotifyPropertyChanged").AppendLine(TypeBodyOpen) - .AppendLine(" public event PropertyChangedEventHandler PropertyChanged;").AppendLine() - .AppendLine(" public string Nested { get; set; }").AppendLine(TypeBodyClose).AppendLine() - .Append(" public class MyViewModel").Append(index).AppendLine(" : INotifyPropertyChanged").AppendLine(TypeBodyOpen) - .AppendLine(" public event PropertyChangedEventHandler PropertyChanged;").AppendLine() - .AppendLine(" public string Name { get; set; }").AppendLine().AppendLine(" public int Count { get; set; }") - .AppendLine().AppendLine(" public bool Flag { get; set; }").AppendLine().Append(" public Child").Append(index) - .AppendLine(" Child { get; set; }").AppendLine().AppendLine(" public ICommand Save { get; set; }").AppendLine(TypeBodyClose) - .AppendLine().Append(" public class MyButton").Append(index).AppendLine().AppendLine(TypeBodyOpen) - .AppendLine(" public event EventHandler Click;").AppendLine(TypeBodyClose).AppendLine().Append(" public class MyView") - .Append(index).Append(" : IViewFor").AppendLine(TypeBodyOpen) - .Append(" public MyViewModel").Append(index).AppendLine(" ViewModel { get; set; }").AppendLine() - .Append(" object IViewFor.ViewModel { get => ViewModel; set => ViewModel = (MyViewModel").Append(index).AppendLine(")value; }") - .AppendLine().AppendLine(" public string NameText { get; set; }").AppendLine() - .AppendLine(" public string CountText { get; set; }").AppendLine() - .AppendLine(" public bool FlagValue { get; set; }").AppendLine().Append(" public MyButton").Append(index) - .AppendLine(" SaveButton { get; set; }").AppendLine(TypeBodyClose); - - /// Appends the call sites for one pair, spread across the observation and binding APIs. - /// The builder to append to. - /// The index that makes the emitted names unique. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void AppendUsage(StringBuilder sb, int index) => - sb.Append(" public static class Usage").Append(index).AppendLine().AppendLine(TypeBodyOpen) - .Append(" public static IObservable ObserveName(MyViewModel").Append(index) - .AppendLine(" vm) => vm.WhenChanged(x => x.Name);").AppendLine() - .Append(" public static IObservable ObserveNested(MyViewModel").Append(index) - .AppendLine(" vm) => vm.WhenChanged(x => x.Child.Nested);").AppendLine() - .Append(" public static IObservable<(string, int)> ObserveBoth(MyViewModel").Append(index) - .AppendLine(" vm) => vm.WhenChanged(x => x.Name, x => x.Count);").AppendLine() - .Append(" public static IObservable ObserveChanging(MyViewModel").Append(index) - .AppendLine(" vm) => vm.WhenChanging(x => x.Count);").AppendLine() - .Append(" public static IObservable AnyValue(MyViewModel").Append(index) - .AppendLine(" vm) => vm.WhenAnyValue(x => x.Name);").AppendLine().Append(" public static IDisposable BindName(MyViewModel") - .Append(index).Append(ViewModelAndViewParameters).Append(index).AppendLine(" view) => vm.BindOneWay(view, x => x.Name, x => x.NameText);").AppendLine() - .Append(" public static IDisposable BindFlag(MyViewModel").Append(index).Append(ViewModelAndViewParameters).Append(index) - .AppendLine(" view) => vm.BindTwoWay(view, x => x.Flag, x => x.FlagValue);").AppendLine() - .Append(" public static IDisposable OneWay(MyViewModel").Append(index).Append(ViewModelAndViewParameters).Append(index) - .AppendLine(" view) => view.OneWayBind(vm, x => x.Name, x => x.NameText);").AppendLine() - .Append(" public static IReactiveBinding TwoWay(MyViewModel").Append(index) - .Append(ViewModelAndViewParameters).Append(index).AppendLine(" view) => view.Bind(vm, x => x.Name, x => x.NameText);").AppendLine() - .Append(" public static IDisposable Command(MyViewModel").Append(index).Append(ViewModelAndViewParameters).Append(index) - .AppendLine(" view) => view.BindCommand(vm, x => x.Save, x => x.SaveButton);").AppendLine() - .Append(" public static IDisposable ToTarget(MyViewModel").Append(index).Append(ViewModelAndViewParameters).Append(index) - .AppendLine(" view) => vm.WhenChanged(x => x.Name).BindTo(view, x => x.NameText);").AppendLine(TypeBodyClose).AppendLine(); -} diff --git a/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Mocks/AddressViewModel.cs b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Mocks/AddressViewModel.cs new file mode 100644 index 00000000..e58f5bad --- /dev/null +++ b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Mocks/AddressViewModel.cs @@ -0,0 +1,30 @@ +// 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.ComponentModel; + +namespace ReactiveUI.Binding.Generator.Benchmarks.Mocks; + +/// A nested view model, so the mock bindings observe a chain of two properties. +public class AddressViewModel : INotifyPropertyChanged +{ + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// Gets or sets the city. + public string City + { + get => field; + set + { + if (field == value) + { + return; + } + + field = value; + PropertyChanged?.Invoke(this, new(nameof(City))); + } + } = string.Empty; +} diff --git a/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Mocks/PersonBindings.cs b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Mocks/PersonBindings.cs new file mode 100644 index 00000000..6b8bacaa --- /dev/null +++ b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Mocks/PersonBindings.cs @@ -0,0 +1,95 @@ +// 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; +using System.Runtime.CompilerServices; + +namespace ReactiveUI.Binding.Generator.Benchmarks.Mocks; + +/// One call site for each observation and binding API the generator writes code for. +public static class PersonBindings +{ + /// Observes the name. + /// The view model to observe. + /// The name, now and after each change. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IObservable ObserveName(PersonViewModel viewModel) => + viewModel.WhenChanged(x => x.Name); + + /// Observes the city through the address. + /// The view model to observe. + /// The city, now and after each change to either link. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IObservable ObserveCity(PersonViewModel viewModel) => + viewModel.WhenChanged(x => x.Address.City); + + /// Observes the name and the age together. + /// The view model to observe. + /// Both values, now and after each change to either. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IObservable> ObserveNameAndAge(PersonViewModel viewModel) => + viewModel.WhenChanged(x => x.Name, x => x.Age); + + /// Observes the age before each change. + /// The view model to observe. + /// The age, now and before each change. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IObservable ObserveAgeChanging(PersonViewModel viewModel) => + viewModel.WhenChanging(x => x.Age); + + /// Observes the name under the ReactiveUI-compatible name. + /// The view model to observe. + /// The name, now and after each change. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IObservable ObserveNameValue(PersonViewModel viewModel) => + viewModel.WhenAnyValue(x => x.Name); + + /// Writes the name to the view. + /// The view model to read. + /// The view to write. + /// The binding. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IDisposable BindName(PersonViewModel viewModel, PersonView view) => + viewModel.BindOneWay(view, x => x.Name, x => x.NameText); + + /// Keeps the active flag and the active box in step. + /// The view model to bind. + /// The view to bind. + /// The binding. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IDisposable BindIsActive(PersonViewModel viewModel, PersonView view) => + viewModel.BindTwoWay(view, x => x.IsActive, x => x.IsActiveValue); + + /// Writes the name to the view, starting from the view. + /// The view model to read. + /// The view to write. + /// The binding. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IDisposable OneWayBindName(PersonViewModel viewModel, PersonView view) => + view.OneWayBind(viewModel, x => x.Name, x => x.NameText); + + /// Keeps the name and the name text in step, starting from the view. + /// The view model to bind. + /// The view to bind. + /// The binding. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IDisposable BindNameBothWays(PersonViewModel viewModel, PersonView view) => + view.Bind(viewModel, x => x.Name, x => x.NameText); + + /// Runs the save command when the save button is clicked. + /// The view model holding the command. + /// The view holding the button. + /// The binding. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IDisposable BindSave(PersonViewModel viewModel, PersonView view) => + view.BindCommand(viewModel, x => x.Save, x => x.SaveButton); + + /// Writes each name the view model reports into the view. + /// The view model to observe. + /// The view to write. + /// The binding. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IDisposable BindNameTo(PersonViewModel viewModel, PersonView view) => + viewModel.WhenChanged(x => x.Name).BindTo(view, x => x.NameText); +} diff --git a/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Mocks/PersonView.cs b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Mocks/PersonView.cs new file mode 100644 index 00000000..70e30e4d --- /dev/null +++ b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Mocks/PersonView.cs @@ -0,0 +1,62 @@ +// 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.Collections.Generic; +using System.ComponentModel; + +namespace ReactiveUI.Binding.Generator.Benchmarks.Mocks; + +/// A view with the controls the mock bindings write to. +public class PersonView : IViewFor, INotifyPropertyChanged +{ + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// + public PersonViewModel? ViewModel + { + get => field; + set => SetField(ref field, value, nameof(ViewModel)); + } + + /// + object? IViewFor.ViewModel + { + get => ViewModel; + set => ViewModel = (PersonViewModel?)value; + } + + /// Gets or sets the text showing the name. + public string NameText + { + get => field; + set => SetField(ref field, value, nameof(NameText)); + } = string.Empty; + + /// Gets or sets a value indicating whether the active box is checked. + public bool IsActiveValue + { + get => field; + set => SetField(ref field, value, nameof(IsActiveValue)); + } + + /// Gets the button that runs the save command. + public SaveButton SaveButton { get; } = new(); + + /// Writes a property's value, raising the change event after the write. + /// The property type. + /// The property's backing field. + /// The new value. + /// The property being written. + private void SetField(ref T storage, T value, string propertyName) + { + if (EqualityComparer.Default.Equals(storage, value)) + { + return; + } + + storage = value; + PropertyChanged?.Invoke(this, new(propertyName)); + } +} diff --git a/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Mocks/PersonViewModel.cs b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Mocks/PersonViewModel.cs new file mode 100644 index 00000000..e7200015 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Mocks/PersonViewModel.cs @@ -0,0 +1,71 @@ +// 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.Collections.Generic; +using System.ComponentModel; +using System.Windows.Input; + +namespace ReactiveUI.Binding.Generator.Benchmarks.Mocks; + +/// A view model with the property shapes the mock bindings read: text, a number, a flag, a nested object and a command. +public class PersonViewModel : INotifyPropertyChanged, INotifyPropertyChanging +{ + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// + public event PropertyChangingEventHandler? PropertyChanging; + + /// Gets or sets the person's name. + public string Name + { + get => field; + set => SetField(ref field, value, nameof(Name)); + } = string.Empty; + + /// Gets or sets the person's age. + public int Age + { + get => field; + set => SetField(ref field, value, nameof(Age)); + } + + /// Gets or sets a value indicating whether the person is active. + public bool IsActive + { + get => field; + set => SetField(ref field, value, nameof(IsActive)); + } + + /// Gets or sets the person's address. + public AddressViewModel Address + { + get => field; + set => SetField(ref field, value, nameof(Address)); + } = new(); + + /// Gets or sets the command that saves the person. + public ICommand? Save + { + get => field; + set => SetField(ref field, value, nameof(Save)); + } + + /// Writes a property's value, raising the change events around the write. + /// The property type. + /// The property's backing field. + /// The new value. + /// The property being written. + private void SetField(ref T storage, T value, string propertyName) + { + if (EqualityComparer.Default.Equals(storage, value)) + { + return; + } + + PropertyChanging?.Invoke(this, new(propertyName)); + storage = value; + PropertyChanged?.Invoke(this, new(propertyName)); + } +} diff --git a/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Mocks/SaveButton.cs b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Mocks/SaveButton.cs new file mode 100644 index 00000000..a496a4dc --- /dev/null +++ b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Mocks/SaveButton.cs @@ -0,0 +1,19 @@ +// 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; +using System.Runtime.CompilerServices; + +namespace ReactiveUI.Binding.Generator.Benchmarks.Mocks; + +/// A button the mock command binding attaches to through its click event. +public class SaveButton +{ + /// Occurs when the button is clicked. + public event EventHandler? Click; + + /// Raises . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void PerformClick() => Click?.Invoke(this, EventArgs.Empty); +} diff --git a/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/ReactiveUI.Binding.Generator.Benchmarks.csproj b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/ReactiveUI.Binding.Generator.Benchmarks.csproj index 551d7c65..b6736990 100644 --- a/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/ReactiveUI.Binding.Generator.Benchmarks.csproj +++ b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/ReactiveUI.Binding.Generator.Benchmarks.csproj @@ -1,15 +1,10 @@ - Exe net8.0;net10.0;net11.0 - enable - false - - @@ -23,4 +18,8 @@ + + + + diff --git a/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/GeneratorHarness.cs b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Support/GeneratorHarness.cs similarity index 67% rename from src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/GeneratorHarness.cs rename to src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Support/GeneratorHarness.cs index 0b5dee3d..286d9767 100644 --- a/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/GeneratorHarness.cs +++ b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Support/GeneratorHarness.cs @@ -7,13 +7,19 @@ using Microsoft.CodeAnalysis.CSharp; using ReactiveUI.Binding.SourceGenerators; -namespace ReactiveUI.Binding.Generator.Benchmarks; +namespace ReactiveUI.Binding.Generator.Benchmarks.Support; -/// Shared setup for the generator benchmarks: compilations and drivers over a corpus. +/// Builds the compilation and the generator driver the generation benchmarks run. internal static class GeneratorHarness { - /// The assembly name given to the throwaway compilation the generator runs against. - private const string CompilationAssemblyName = "Corpus"; + /// The assembly name given to the compilation the generator runs against. + private const string CompilationAssemblyName = "Mocks"; + + /// The folder beside the benchmark assembly that holds the mock consumer source. + private const string MocksFolder = "Mocks"; + + /// Matches the C# source files in the mocks folder. + private const string SourceFilePattern = "*.cs"; /// The feature a build lists interceptable namespaces under. private const string InterceptorsNamespacesFeature = "InterceptorsNamespaces"; @@ -29,20 +35,28 @@ internal static class GeneratorHarness /// The parse options. internal static CSharpParseOptions ParseOptions(bool intercept) { - var parseOptions = new CSharpParseOptions(LanguageVersion.CSharp10); + var parseOptions = new CSharpParseOptions(LanguageVersion.Latest); return intercept ? parseOptions.WithFeatures([new KeyValuePair(InterceptorsNamespacesFeature, InterceptorNamespace)]) : parseOptions; } - /// Builds a compilation over the corpus source. - /// The corpus source text. + /// Builds a compilation over the mock consumer source copied beside the benchmark assembly. /// Whether the build lists the generated namespace for interception. /// The compilation. - internal static CSharpCompilation BuildCompilation(string sourceText, bool intercept) + internal static CSharpCompilation BuildCompilation(bool intercept) { - var syntaxTree = CSharpSyntaxTree.ParseText(sourceText, ParseOptions(intercept)); + var parseOptions = ParseOptions(intercept); + var paths = Directory.GetFiles(Path.Combine(AppContext.BaseDirectory, MocksFolder), SourceFilePattern); + Array.Sort(paths, StringComparer.Ordinal); + + var syntaxTrees = new SyntaxTree[paths.Length]; + for (var i = 0; i < paths.Length; i++) + { + using var reader = File.OpenText(paths[i]); + syntaxTrees[i] = CSharpSyntaxTree.ParseText(reader.ReadToEnd(), parseOptions, paths[i]); + } var references = new List(Basic.Reference.Assemblies.Net80.References.All) { @@ -53,7 +67,7 @@ internal static CSharpCompilation BuildCompilation(string sourceText, bool inter return CSharpCompilation.Create( CompilationAssemblyName, - [syntaxTree], + syntaxTrees, references, new(OutputKind.DynamicallyLinkedLibrary)); } diff --git a/src/benchmarks/Shared/Configs/NativeAotBenchmarkConfig.cs b/src/benchmarks/Shared/Configs/NativeAotBenchmarkConfig.cs new file mode 100644 index 00000000..ebab9439 --- /dev/null +++ b/src/benchmarks/Shared/Configs/NativeAotBenchmarkConfig.cs @@ -0,0 +1,19 @@ +// 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 BenchmarkDotNet.Environments; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Binding.Benchmarks.Configs; + +/// Adds the NativeAOT runtimes to , for benchmarks whose code publishes ahead of time. +public class NativeAotBenchmarkConfig : BenchmarkConfig +{ + /// Initializes a new instance of the class. + public NativeAotBenchmarkConfig() + { + _ = AddJob(new Job(nameof(RuntimeMoniker.NativeAot10_0)).WithRuntime(NativeAotRuntime.Net10_0)); + _ = AddJob(new Job(nameof(RuntimeMoniker.NativeAot11_0)).WithRuntime(NativeAotRuntime.Net11_0)); + } +} diff --git a/src/benchmarks/Shared/Platforms/unix/BenchmarkConfig.cs b/src/benchmarks/Shared/Platforms/unix/BenchmarkConfig.cs new file mode 100644 index 00000000..876a4e41 --- /dev/null +++ b/src/benchmarks/Shared/Platforms/unix/BenchmarkConfig.cs @@ -0,0 +1,22 @@ +// 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 BenchmarkDotNet.Environments; +using BenchmarkDotNet.Exporters; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Binding.Benchmarks.Configs; + +/// Runs a benchmark on .NET 8, 10 and 11. +public class BenchmarkConfig : ProfilerConfig +{ + /// Initializes a new instance of the class. + public BenchmarkConfig() + { + _ = AddJob(new Job().WithRuntime(CoreRuntime.Core80)); + _ = AddJob(new Job().WithRuntime(CoreRuntime.Core10_0)); + _ = AddJob(new Job().WithRuntime(CoreRuntime.Core11_0)); + _ = AddExporter(MarkdownExporter.GitHub); + } +} diff --git a/src/benchmarks/Shared/Platforms/unix/ProfilerConfig.cs b/src/benchmarks/Shared/Platforms/unix/ProfilerConfig.cs new file mode 100644 index 00000000..d8398b94 --- /dev/null +++ b/src/benchmarks/Shared/Platforms/unix/ProfilerConfig.cs @@ -0,0 +1,38 @@ +// 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.Tracing; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using Microsoft.Diagnostics.NETCore.Client; +using Microsoft.Diagnostics.Tracing.Parsers; + +namespace ReactiveUI.Binding.Benchmarks.Configs; + +/// Traces every benchmark with EventPipe, recording CPU samples and verbose GC events in one trace. +/// +/// A config keeps one EventPipe profiler, and the CPU and GC profiles both enable the runtime provider. So the +/// sample profiler is added beside the GC profile, and the runtime provider carries the keywords of both. +/// +public class ProfilerConfig : ManualConfig +{ + /// The provider that samples CPU stacks. + private const string SampleProfilerProviderName = "Microsoft-DotNETCore-SampleProfiler"; + + /// The runtime keywords of both profiles: the CPU profile's defaults, which include GC and exceptions, plus GC handles. + private const ClrTraceEventParser.Keywords RuntimeKeywords = + ClrTraceEventParser.Keywords.Default | ClrTraceEventParser.Keywords.GCHandle; + + /// Initializes a new instance of the class. + public ProfilerConfig() + { + EventPipeProvider[] providers = + [ + new(SampleProfilerProviderName, EventLevel.Informational), + new(ClrTraceEventParser.ProviderName, EventLevel.Verbose, (long)RuntimeKeywords), + ]; + + _ = AddDiagnoser(new EventPipeProfiler(EventPipeProfile.GcVerbose, providers)); + } +} diff --git a/src/benchmarks/Shared/Platforms/windows/BenchmarkConfig.cs b/src/benchmarks/Shared/Platforms/windows/BenchmarkConfig.cs new file mode 100644 index 00000000..fe087b89 --- /dev/null +++ b/src/benchmarks/Shared/Platforms/windows/BenchmarkConfig.cs @@ -0,0 +1,23 @@ +// 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 BenchmarkDotNet.Environments; +using BenchmarkDotNet.Exporters; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Binding.Benchmarks.Configs; + +/// Runs a benchmark on .NET Framework 4.6.2 and on .NET 8, 10 and 11. +public class BenchmarkConfig : ProfilerConfig +{ + /// Initializes a new instance of the class. + public BenchmarkConfig() + { + _ = AddJob(new Job().WithRuntime(ClrRuntime.Net462)); + _ = AddJob(new Job().WithRuntime(CoreRuntime.Core80)); + _ = AddJob(new Job().WithRuntime(CoreRuntime.Core10_0)); + _ = AddJob(new Job().WithRuntime(CoreRuntime.Core11_0)); + _ = AddExporter(MarkdownExporter.GitHub); + } +} diff --git a/src/benchmarks/Shared/Platforms/windows/ProfilerConfig.cs b/src/benchmarks/Shared/Platforms/windows/ProfilerConfig.cs new file mode 100644 index 00000000..5b2d1df0 --- /dev/null +++ b/src/benchmarks/Shared/Platforms/windows/ProfilerConfig.cs @@ -0,0 +1,15 @@ +// 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 BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnostics.Windows; + +namespace ReactiveUI.Binding.Benchmarks.Configs; + +/// Traces every benchmark with ETW, which records CPU samples and GC events for .NET Framework and .NET alike. +public class ProfilerConfig : ManualConfig +{ + /// Initializes a new instance of the class. + public ProfilerConfig() => _ = AddDiagnoser(new EtwProfiler(new EtwProfilerConfig())); +}