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 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/Program.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/Program.cs index ece84667..4d9512e0 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/Program.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/Program.cs @@ -2,7 +2,8 @@ // 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.Running; +using System.Runtime.CompilerServices; +using ReactiveUI.Binding.Benchmarks.Configs; namespace ReactiveUI.Binding.Benchmarks.ReactiveUI; @@ -12,6 +13,6 @@ internal static class Program /// Runs the benchmarks selected by . /// The command line arguments passed to the benchmark switcher. [STAThread] - internal static void Main(string[] args) => - _ = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static void Main(string[] args) => BenchmarkHost.Run(typeof(Program).Assembly, args); } 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..5fe3a240 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ReactiveUIBindingBenchmark.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ReactiveUIBindingBenchmark.cs @@ -4,23 +4,13 @@ using System.Diagnostics; using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; +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] [DebuggerDisplay("Expression-tree binding over {PropertyChangeCount} changes")] public class ReactiveUIBindingBenchmark { @@ -33,8 +23,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..588eaffe 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ReactiveUIObservationBenchmark.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ReactiveUIObservationBenchmark.cs @@ -4,23 +4,13 @@ using System.Diagnostics; using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; +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] [DebuggerDisplay("Expression-tree observation over {PropertyChangeCount} changes")] public class ReactiveUIObservationBenchmark { @@ -30,8 +20,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 new file mode 100644 index 00000000..8ce85416 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/InteractionBenchmark.cs @@ -0,0 +1,79 @@ +// 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 ReactiveUI.Binding.Benchmarks.Configs; + +namespace ReactiveUI.Binding.Benchmarks; + +/// Interaction benchmarks: asking a question, and registering and removing handlers. +[Config(typeof(NativeAotBenchmarkConfig))] +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/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/Program.cs b/src/benchmarks/ReactiveUI.Binding.Benchmarks/Program.cs index 01ee9382..498ce3cd 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/Program.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/Program.cs @@ -2,16 +2,17 @@ // 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.Running; +using System.Runtime.CompilerServices; +using ReactiveUI.Binding.Benchmarks.Configs; namespace ReactiveUI.Binding.Benchmarks; -/// Entry point that hands the command line to the BenchmarkDotNet switcher. +/// Entry point that hands the command line to the benchmark host. internal static class Program { - /// Runs the benchmark selected by the command line. + /// Runs the benchmarks selected by the command line. /// The command-line arguments passed to the switcher. [STAThread] - internal static void Main(string[] args) => - _ = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static void Main(string[] args) => BenchmarkHost.Run(typeof(Program).Assembly, args); } 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..f3a37922 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/RxUiDynamicChainBaseline.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/RxUiDynamicChainBaseline.cs @@ -8,28 +8,15 @@ using System.Linq.Expressions; using System.Runtime.CompilerServices; using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; using ReactiveUI; 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] #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..03220a40 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/UnsafeFallbackBenchmark.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/UnsafeFallbackBenchmark.cs @@ -6,8 +6,8 @@ using System.Diagnostics.CodeAnalysis; #endif using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; +using ReactiveUI.Binding.Benchmarks.Mocks; +using ReactiveUI.Binding.Builder; namespace ReactiveUI.Binding.Benchmarks; @@ -17,17 +17,6 @@ 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] #if NET8_0_OR_GREATER [RequiresUnreferencedCode("Evaluates expression-based member chains via reflection; members may be trimmed.")] #endif @@ -42,8 +31,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..3a19c110 100644 --- a/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenAnyDynamicBenchmark.cs +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenAnyDynamicBenchmark.cs @@ -8,24 +8,12 @@ using System.Linq.Expressions; using System.Runtime.CompilerServices; using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Jobs; +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] #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 new file mode 100644 index 00000000..6cd4a2b6 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Binding.Benchmarks/WhenChangedContentionBenchmark.cs @@ -0,0 +1,48 @@ +// 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 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. +[Config(typeof(NativeAotBenchmarkConfig))] +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; + } + } +} 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/BenchmarkConfig.cs b/src/benchmarks/Shared/Configs/BenchmarkConfig.cs new file mode 100644 index 00000000..6c98eddf --- /dev/null +++ b/src/benchmarks/Shared/Configs/BenchmarkConfig.cs @@ -0,0 +1,24 @@ +// 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.Environments; +using BenchmarkDotNet.Exporters; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Binding.Benchmarks.Configs; + +/// Runs benchmarks on .NET 8, 10 and 11, traced with EventPipe. +public class BenchmarkConfig : ProfilerConfig +{ + /// Initializes a new instance of the class. + public BenchmarkConfig() + { + Add(DefaultConfig.Instance); + _ = 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/Configs/NativeAotBenchmarkConfig.cs b/src/benchmarks/Shared/Configs/NativeAotBenchmarkConfig.cs new file mode 100644 index 00000000..db920f7a --- /dev/null +++ b/src/benchmarks/Shared/Configs/NativeAotBenchmarkConfig.cs @@ -0,0 +1,20 @@ +// 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.Environments; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Binding.Benchmarks.Configs; + +/// Adds the NativeAOT runtimes, for benchmarks whose code publishes ahead of time. +public class NativeAotBenchmarkConfig : ManualConfig +{ + /// 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/Configs/ProfilerConfig.cs b/src/benchmarks/Shared/Configs/ProfilerConfig.cs new file mode 100644 index 00000000..fdc16c3b --- /dev/null +++ b/src/benchmarks/Shared/Configs/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 a 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/unix/BenchmarkHost.cs b/src/benchmarks/Shared/Platforms/unix/BenchmarkHost.cs new file mode 100644 index 00000000..cac4b6a7 --- /dev/null +++ b/src/benchmarks/Shared/Platforms/unix/BenchmarkHost.cs @@ -0,0 +1,18 @@ +// 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.Reflection; +using BenchmarkDotNet.Running; + +namespace ReactiveUI.Binding.Benchmarks.Configs; + +/// Runs the benchmarks on .NET with EventPipe. +public static class BenchmarkHost +{ + /// Runs the benchmarks the command line selects. + /// The assembly holding the benchmarks. + /// The command line arguments passed to the benchmark switcher. + public static void Run(Assembly assembly, string[] args) => + _ = BenchmarkSwitcher.FromAssembly(assembly).Run(args, new BenchmarkConfig()); +} diff --git a/src/benchmarks/Shared/Platforms/windows/BenchmarkHost.cs b/src/benchmarks/Shared/Platforms/windows/BenchmarkHost.cs new file mode 100644 index 00000000..3153e4a9 --- /dev/null +++ b/src/benchmarks/Shared/Platforms/windows/BenchmarkHost.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 System.Reflection; +using BenchmarkDotNet.Running; + +namespace ReactiveUI.Binding.Benchmarks.Configs; + +/// Runs the benchmarks on .NET with EventPipe, then on .NET Framework with ETW. +public static class BenchmarkHost +{ + /// Runs the benchmarks the command line selects. + /// The assembly holding the benchmarks. + /// The command line arguments passed to the benchmark switcher. + public static void Run(Assembly assembly, string[] args) + { + var switcher = BenchmarkSwitcher.FromAssembly(assembly); + _ = switcher.Run(args, new BenchmarkConfig()); + _ = switcher.Run(args, new NetFrameworkConfig()); + } +} diff --git a/src/benchmarks/Shared/Platforms/windows/NetFrameworkConfig.cs b/src/benchmarks/Shared/Platforms/windows/NetFrameworkConfig.cs new file mode 100644 index 00000000..edab2969 --- /dev/null +++ b/src/benchmarks/Shared/Platforms/windows/NetFrameworkConfig.cs @@ -0,0 +1,35 @@ +// 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; +using BenchmarkDotNet.Environments; +using BenchmarkDotNet.Exporters; +using BenchmarkDotNet.Filters; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Binding.Benchmarks.Configs; + +/// Runs benchmarks on .NET Framework 4.6.2, traced with ETW. +/// +/// EventPipe cannot attach to .NET Framework, and a run keeps one set of profilers, so this runs apart from +/// . The filter keeps only the .NET Framework job, dropping the NativeAOT jobs a +/// benchmark class adds for the other run. +/// +public class NetFrameworkConfig : ManualConfig +{ + /// The id of the .NET Framework job, which the filter keeps. + private const string NetFrameworkJobId = "Net462"; + + /// Initializes a new instance of the class. + public NetFrameworkConfig() + { + Add(DefaultConfig.Instance); + _ = AddJob(new Job(NetFrameworkJobId).WithRuntime(ClrRuntime.Net462)); + _ = AddDiagnoser(new EtwProfiler(new EtwProfilerConfig())); + _ = AddExporter(MarkdownExporter.GitHub); + _ = AddFilter(new SimpleFilter(static benchmark => benchmark.Job.Id == NetFrameworkJobId)); + _ = WithOption(ConfigOptions.DontOverwriteResults, true); + } +}