From fff1f759b2e0f19fff80607718d8d648729277d7 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:33:54 +1000 Subject: [PATCH 1/4] test(benchmarks): run NativeAOT jobs with the memory diagnoser --- .../Shared/Configs/BenchmarkConfig.cs | 3 ++ .../Configs/NativeAotBenchmarkConfig.cs | 17 +++++++++-- .../Shared/Configs/NativeAotMemoryConfig.cs | 29 +++++++++++++++++++ .../Shared/Platforms/unix/BenchmarkHost.cs | 10 +++++-- .../Shared/Platforms/windows/BenchmarkHost.cs | 3 +- 5 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 src/benchmarks/Shared/Configs/NativeAotMemoryConfig.cs diff --git a/src/benchmarks/Shared/Configs/BenchmarkConfig.cs b/src/benchmarks/Shared/Configs/BenchmarkConfig.cs index 6c98eddf..71e58237 100644 --- a/src/benchmarks/Shared/Configs/BenchmarkConfig.cs +++ b/src/benchmarks/Shared/Configs/BenchmarkConfig.cs @@ -5,11 +5,13 @@ using BenchmarkDotNet.Configs; using BenchmarkDotNet.Environments; using BenchmarkDotNet.Exporters; +using BenchmarkDotNet.Filters; using BenchmarkDotNet.Jobs; namespace ReactiveUI.Binding.Benchmarks.Configs; /// Runs benchmarks on .NET 8, 10 and 11, traced with EventPipe. +/// The filter drops the NativeAOT jobs a benchmark class adds, which runs. public class BenchmarkConfig : ProfilerConfig { /// Initializes a new instance of the class. @@ -20,5 +22,6 @@ public BenchmarkConfig() _ = AddJob(new Job().WithRuntime(CoreRuntime.Core10_0)); _ = AddJob(new Job().WithRuntime(CoreRuntime.Core11_0)); _ = AddExporter(MarkdownExporter.GitHub); + _ = AddFilter(new SimpleFilter(static benchmark => !NativeAotBenchmarkConfig.IsNativeAotJob(benchmark.Job))); } } diff --git a/src/benchmarks/Shared/Configs/NativeAotBenchmarkConfig.cs b/src/benchmarks/Shared/Configs/NativeAotBenchmarkConfig.cs index db920f7a..b629425f 100644 --- a/src/benchmarks/Shared/Configs/NativeAotBenchmarkConfig.cs +++ b/src/benchmarks/Shared/Configs/NativeAotBenchmarkConfig.cs @@ -2,6 +2,7 @@ // 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 BenchmarkDotNet.Configs; using BenchmarkDotNet.Environments; using BenchmarkDotNet.Jobs; @@ -11,10 +12,22 @@ namespace ReactiveUI.Binding.Benchmarks.Configs; /// Adds the NativeAOT runtimes, for benchmarks whose code publishes ahead of time. public class NativeAotBenchmarkConfig : ManualConfig { + /// The id of the NativeAOT 10 job. + private const string NativeAot10JobId = nameof(RuntimeMoniker.NativeAot10_0); + + /// The id of the NativeAOT 11 job. + private const string NativeAot11JobId = nameof(RuntimeMoniker.NativeAot11_0); + /// 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)); + _ = AddJob(new Job(NativeAot10JobId).WithRuntime(NativeAotRuntime.Net10_0)); + _ = AddJob(new Job(NativeAot11JobId).WithRuntime(NativeAotRuntime.Net11_0)); } + + /// Gets a value indicating whether a job is one of the NativeAOT jobs this config adds. + /// The job to check. + /// for a NativeAOT job; otherwise, . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsNativeAotJob(Job job) => job.Id is NativeAot10JobId or NativeAot11JobId; } diff --git a/src/benchmarks/Shared/Configs/NativeAotMemoryConfig.cs b/src/benchmarks/Shared/Configs/NativeAotMemoryConfig.cs new file mode 100644 index 00000000..d572483a --- /dev/null +++ b/src/benchmarks/Shared/Configs/NativeAotMemoryConfig.cs @@ -0,0 +1,29 @@ +// 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.Diagnosers; +using BenchmarkDotNet.Exporters; +using BenchmarkDotNet.Filters; + +namespace ReactiveUI.Binding.Benchmarks.Configs; + +/// Runs the NativeAOT jobs with the memory diagnoser. +/// +/// EventPipe cannot attach to a NativeAOT process built with the default settings, and a run keeps one set of +/// diagnosers, so these jobs run apart from . The filter keeps only the NativeAOT jobs +/// a benchmark class adds through . +/// +public class NativeAotMemoryConfig : ManualConfig +{ + /// Initializes a new instance of the class. + public NativeAotMemoryConfig() + { + Add(DefaultConfig.Instance); + _ = AddDiagnoser(MemoryDiagnoser.Default); + _ = AddExporter(MarkdownExporter.GitHub); + _ = AddFilter(new SimpleFilter(static benchmark => NativeAotBenchmarkConfig.IsNativeAotJob(benchmark.Job))); + _ = WithOption(ConfigOptions.DontOverwriteResults, true); + } +} diff --git a/src/benchmarks/Shared/Platforms/unix/BenchmarkHost.cs b/src/benchmarks/Shared/Platforms/unix/BenchmarkHost.cs index cac4b6a7..8792da63 100644 --- a/src/benchmarks/Shared/Platforms/unix/BenchmarkHost.cs +++ b/src/benchmarks/Shared/Platforms/unix/BenchmarkHost.cs @@ -7,12 +7,16 @@ namespace ReactiveUI.Binding.Benchmarks.Configs; -/// Runs the benchmarks on .NET with EventPipe. +/// Runs the benchmarks on .NET with EventPipe, and on NativeAOT with the memory diagnoser. 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()); + public static void Run(Assembly assembly, string[] args) + { + var switcher = BenchmarkSwitcher.FromAssembly(assembly); + _ = switcher.Run(args, new BenchmarkConfig()); + _ = switcher.Run(args, new NativeAotMemoryConfig()); + } } diff --git a/src/benchmarks/Shared/Platforms/windows/BenchmarkHost.cs b/src/benchmarks/Shared/Platforms/windows/BenchmarkHost.cs index 3153e4a9..397285a4 100644 --- a/src/benchmarks/Shared/Platforms/windows/BenchmarkHost.cs +++ b/src/benchmarks/Shared/Platforms/windows/BenchmarkHost.cs @@ -7,7 +7,7 @@ namespace ReactiveUI.Binding.Benchmarks.Configs; -/// Runs the benchmarks on .NET with EventPipe, then on .NET Framework with ETW. +/// Runs the benchmarks on .NET with EventPipe, on .NET Framework with ETW, and on NativeAOT with the memory diagnoser. public static class BenchmarkHost { /// Runs the benchmarks the command line selects. @@ -18,5 +18,6 @@ public static void Run(Assembly assembly, string[] args) var switcher = BenchmarkSwitcher.FromAssembly(assembly); _ = switcher.Run(args, new BenchmarkConfig()); _ = switcher.Run(args, new NetFrameworkConfig()); + _ = switcher.Run(args, new NativeAotMemoryConfig()); } } From 1f1b8c12cf7585e9c044f85fe1d7468e31108a1e Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:37:59 +1000 Subject: [PATCH 2/4] ci(benchmarks): benchmark a chosen commit --- .github/workflows/benchmarks.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index ac642b42..6a6499a1 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -2,6 +2,11 @@ name: Benchmarks on: workflow_dispatch: + inputs: + sha: + description: 'Commit to benchmark. Empty benchmarks the latest commit of the chosen branch.' + type: string + default: '' permissions: contents: read @@ -10,6 +15,7 @@ jobs: benchmark: uses: reactiveui/actions-common/.github/workflows/workflow-common-benchmarks.yml@main with: + ref: ${{ inputs.sha }} projects: | benchmarks/ReactiveUI.Binding.Benchmarks/ReactiveUI.Binding.Benchmarks.csproj benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ReactiveUI.Binding.Benchmarks.ReactiveUI.csproj From 3994a0a043ed3f5bb73bbd5560d93b7b7fd0bcd9 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:56:33 +1000 Subject: [PATCH 3/4] ci(benchmarks): compare two commits on one runner --- .github/workflows/benchmarks-ab.yml | 28 +++++++++++++++++++ .../Shared/Configs/BenchmarkProfiling.cs | 20 +++++++++++++ .../Shared/Configs/ProfilerConfig.cs | 5 ++++ .../Platforms/windows/NetFrameworkConfig.cs | 6 +++- 4 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/benchmarks-ab.yml create mode 100644 src/benchmarks/Shared/Configs/BenchmarkProfiling.cs diff --git a/.github/workflows/benchmarks-ab.yml b/.github/workflows/benchmarks-ab.yml new file mode 100644 index 00000000..fe5f709c --- /dev/null +++ b/.github/workflows/benchmarks-ab.yml @@ -0,0 +1,28 @@ +name: Benchmarks A/B + +on: + workflow_dispatch: + inputs: + base: + description: 'Baseline commit SHA.' + type: string + required: true + head: + description: 'Commit SHA compared against the baseline.' + type: string + required: true + +permissions: + contents: read + +jobs: + benchmark: + uses: reactiveui/actions-common/.github/workflows/workflow-common-benchmarks-ab.yml@main + with: + base: ${{ inputs.base }} + head: ${{ inputs.head }} + projects: | + benchmarks/ReactiveUI.Binding.Benchmarks/ReactiveUI.Binding.Benchmarks.csproj + benchmarks/ReactiveUI.Binding.Generator.Benchmarks/ReactiveUI.Binding.Generator.Benchmarks.csproj + solutionFile: ReactiveUI.Binding.SourceGenerators.slnx + installWorkloads: true diff --git a/src/benchmarks/Shared/Configs/BenchmarkProfiling.cs b/src/benchmarks/Shared/Configs/BenchmarkProfiling.cs new file mode 100644 index 00000000..70ad8705 --- /dev/null +++ b/src/benchmarks/Shared/Configs/BenchmarkProfiling.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. + +namespace ReactiveUI.Binding.Benchmarks.Configs; + +/// Reads whether the benchmark configs attach their profilers. +/// +/// An A/B comparison measures timing only. It sets BENCHMARK_PROFILERS to false, which skips the +/// second pass each profiler adds to every benchmark. +/// +public static class BenchmarkProfiling +{ + /// The environment variable that turns the profilers off when set to false. + private const string VariableName = "BENCHMARK_PROFILERS"; + + /// Gets a value indicating whether the profilers are attached. + public static bool Enabled => + !string.Equals(Environment.GetEnvironmentVariable(VariableName), "false", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/benchmarks/Shared/Configs/ProfilerConfig.cs b/src/benchmarks/Shared/Configs/ProfilerConfig.cs index fdc16c3b..ea62c359 100644 --- a/src/benchmarks/Shared/Configs/ProfilerConfig.cs +++ b/src/benchmarks/Shared/Configs/ProfilerConfig.cs @@ -27,6 +27,11 @@ public class ProfilerConfig : ManualConfig /// Initializes a new instance of the class. public ProfilerConfig() { + if (!BenchmarkProfiling.Enabled) + { + return; + } + EventPipeProvider[] providers = [ new(SampleProfilerProviderName, EventLevel.Informational), diff --git a/src/benchmarks/Shared/Platforms/windows/NetFrameworkConfig.cs b/src/benchmarks/Shared/Platforms/windows/NetFrameworkConfig.cs index edab2969..0bf0ad51 100644 --- a/src/benchmarks/Shared/Platforms/windows/NetFrameworkConfig.cs +++ b/src/benchmarks/Shared/Platforms/windows/NetFrameworkConfig.cs @@ -27,9 +27,13 @@ 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); + + if (BenchmarkProfiling.Enabled) + { + _ = AddDiagnoser(new EtwProfiler(new EtwProfilerConfig())); + } } } From 12cde61874249756d7b6a4dcc7d537ae54e9b3a2 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Tue, 15 Sep 2026 01:23:50 +1000 Subject: [PATCH 4/4] test(interaction): cover the handler list a subclass reads --- .../Interactions/InteractionTests.cs | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/tests/ReactiveUI.Binding.Tests/Interactions/InteractionTests.cs b/src/tests/ReactiveUI.Binding.Tests/Interactions/InteractionTests.cs index 70e81a98..417d6933 100644 --- a/src/tests/ReactiveUI.Binding.Tests/Interactions/InteractionTests.cs +++ b/src/tests/ReactiveUI.Binding.Tests/Interactions/InteractionTests.cs @@ -2,6 +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 System.Runtime.CompilerServices; + namespace ReactiveUI.Binding.Tests.Interactions; /// Tests for . @@ -10,6 +12,9 @@ public class InteractionTests /// The result the first registered handler produces. private const string FirstHandlerResult = "first"; + /// The result the second registered handler produces. + private const string SecondHandlerResult = "second"; + /// The expected output of the synchronous handler (the length of "hello"). private const int HelloLength = 5; @@ -70,10 +75,27 @@ public async Task Handle_MultipleHandlers_LIFOOrder() { var interaction = new Interaction(); using var first = interaction.RegisterHandler(static ctx => ctx.SetOutput(FirstHandlerResult)); - using var second = interaction.RegisterHandler(static ctx => ctx.SetOutput("second")); + using var second = interaction.RegisterHandler(static ctx => ctx.SetOutput(SecondHandlerResult)); var result = await interaction.Handle("input"); - await Assert.That(result).IsEqualTo("second"); + await Assert.That(result).IsEqualTo(SecondHandlerResult); + } + + /// Verifies that GetHandlers hands back a copy, so changing it leaves the registered handlers alone. + /// A representing the asynchronous unit test. + [Test] + public async Task GetHandlers_ReturnsACopyOfTheRegisteredHandlers() + { + var interaction = new HandlerListingInteraction(); + using var first = interaction.RegisterHandler(static ctx => ctx.SetOutput(FirstHandlerResult)); + using var second = interaction.RegisterHandler(static ctx => ctx.SetOutput(SecondHandlerResult)); + + var handlers = interaction.ListHandlers(); + Array.Clear(handlers); + + await Assert.That(interaction.ListHandlers().Length).IsEqualTo(2); + await Assert.That(interaction.ListHandlers()[1]).IsNotNull(); + await Assert.That(await interaction.Handle("input")).IsEqualTo(SecondHandlerResult); } /// Verifies that Handle throws UnhandledInteractionException when no handler calls SetOutput. @@ -190,4 +212,13 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } } + + /// An interaction that exposes its protected handler list to the tests. + private sealed class HandlerListingInteraction : Interaction + { + /// Lists the registered handlers. + /// The registered handlers, in registration order. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Func, Task>[] ListHandlers() => GetHandlers(); + } }