From 6233643ccb7e6f4395a9d0eb745d7d96cb65a953 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:08:08 +1100 Subject: [PATCH] perf(generator): share semantic models per source file - Reuse bound members across call sites in a compilation copy. - Add regression coverage for model reuse. - Measure XAML view generation across binding counts. --- CLAUDE.md | 3 + .../Helpers/CallSiteContext.cs | 27 +- src/benchmarks/README.md | 16 + .../Support/GeneratorHarness.cs | 11 +- .../Support/XamlViewCorpus.cs | 323 ++++++++++++++++++ .../XamlViewGenerationBenchmarks.cs | 125 +++++++ src/benchmarks/issue-166-results.md | 95 ++++++ .../CallSiteModelSharingTests.cs | 285 ++++++++++++++++ 8 files changed, 883 insertions(+), 2 deletions(-) create mode 100644 src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Support/XamlViewCorpus.cs create mode 100644 src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/XamlViewGenerationBenchmarks.cs create mode 100644 src/benchmarks/issue-166-results.md create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/CallSiteModelSharingTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index e5495c6d..3ca46be2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -486,6 +486,9 @@ and nothing is generated. A class marked `[IReactiveObject]` looks as if it rais The file is never emitted. - `BindingGenerator.Detect` reads every call site from that copy, through `CallSiteContext`. A compilation that uses none of the attributes gets no copy, and its call sites are read from the consumer's compilation as before. +- Every call site in one file shares one semantic model of the copy (`CallSiteContext.ModelFor`). A model caches the + methods it binds, and binding one call site binds its whole method. A new model per call site binds a view's + `WhenActivated` lambda once per binding in it, which grows with the square of the bindings. The rules mirror ReactiveUI.SourceGenerators 4.0.0. Its session agreed to announce any change to a name, type or attribute argument. When one changes, update `SourceGeneratorsMemberExtractor` and `SourceGeneratorsDeclarationTests`. diff --git a/src/ReactiveUI.Binding.SourceGenerators/Helpers/CallSiteContext.cs b/src/ReactiveUI.Binding.SourceGenerators/Helpers/CallSiteContext.cs index 978721b1..8257417b 100644 --- a/src/ReactiveUI.Binding.SourceGenerators/Helpers/CallSiteContext.cs +++ b/src/ReactiveUI.Binding.SourceGenerators/Helpers/CallSiteContext.cs @@ -2,6 +2,8 @@ // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; using Microsoft.CodeAnalysis; namespace ReactiveUI.Binding.SourceGenerators.Helpers; @@ -15,10 +17,33 @@ namespace ReactiveUI.Binding.SourceGenerators.Helpers; /// internal readonly record struct CallSiteContext(SyntaxNode Node, SemanticModel SemanticModel) { + /// The semantic model of each file of each compilation copy, shared by every call site in the file. + /// + /// A semantic model caches the members it binds. Call sites in one method share that cache. + /// The table holds the models only while the compilation copy is alive. + /// + private static readonly ConditionalWeakTable> Models = new(); + /// Reads a call site from the compilation that declares ReactiveUI.SourceGenerators' members, when there is one. /// The syntax context the scan found the call site in. /// The compilation with those members declared, or null when the consumer uses none. /// The call site with the model to read it with. internal static CallSiteContext From(GeneratorSyntaxContext context, Compilation? sourceGenerators) => - new(context.Node, sourceGenerators?.GetSemanticModel(context.Node.SyntaxTree) ?? context.SemanticModel); + new(context.Node, sourceGenerators is null ? context.SemanticModel : ModelFor(sourceGenerators, context.Node.SyntaxTree)); + + /// Gets the one semantic model a compilation's file is read with. + /// The compilation. + /// The file. + /// The file's model, created on first use. + /// + /// Transforms run in parallel. A semantic model supports queries from several threads. + /// Two first readers can each create a model. Both receive the model the dictionary keeps. + /// + internal static SemanticModel ModelFor(Compilation compilation, SyntaxTree tree) + { + var models = Models.GetValue(compilation, static _ => new()); + return models.TryGetValue(tree, out var model) + ? model + : models.GetOrAdd(tree, compilation.GetSemanticModel(tree)); + } } diff --git a/src/benchmarks/README.md b/src/benchmarks/README.md index cfd7951e..bf7939bb 100644 --- a/src/benchmarks/README.md +++ b/src/benchmarks/README.md @@ -115,6 +115,22 @@ The `GcVerbose` EventPipe profiler records allocations. Analysis uses the measur operation counts to report sampled bytes per generation and the allocation sites. NativeAOT timing runs remain separate where EventPipe capture is unavailable. +## Views that name their controls in XAML + +`XamlViewGenerationBenchmarks` builds four Avalonia-style views. Each view names its controls in an `.axaml` +page and binds every control in one `WhenActivated` lambda. Each view model has a `[Reactive]` field. Either +one makes the generator read call sites from its private copy of the compilation. + +| Parameter | Values | Meaning | +|-----------|--------|---------| +| `Bindings` | 10, 50, 100 | How many controls each view names and binds. | +| `Xaml` | false, true | Whether the controls come from the pages, or the views declare them as fields. | + +`Generate` runs a cold pass. `Edit` runs the pass after a one-line edit to one view, with a driver primed on +the unedited views. Setup fails when the output does not compile with the members the other generators add. + +See [the issue 166 measurements](issue-166-results.md) for the comparison against the baseline. + ## What the analyzer benchmark covers `AnalyzerBenchmarks` runs each analyzer over a corpus through `CompilationWithAnalyzers`. It has two cases, diff --git a/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Support/GeneratorHarness.cs b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Support/GeneratorHarness.cs index 40c53f7d..cba8d2a1 100644 --- a/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Support/GeneratorHarness.cs +++ b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Support/GeneratorHarness.cs @@ -58,6 +58,15 @@ internal static CSharpCompilation BuildCompilation(bool intercept) syntaxTrees[i] = CSharpSyntaxTree.ParseText(reader.ReadToEnd(), parseOptions, paths[i]); } + return BuildCompilation(CompilationAssemblyName, syntaxTrees); + } + + /// Builds a compilation over the given source, referencing the framework and the runtime library. + /// The compilation's assembly name. + /// The source. + /// The compilation. + internal static CSharpCompilation BuildCompilation(string assemblyName, SyntaxTree[] syntaxTrees) + { #if NET11_0_OR_GREATER var references = new List(Basic.Reference.Assemblies.Net110.References.All) #else @@ -70,7 +79,7 @@ internal static CSharpCompilation BuildCompilation(bool intercept) }; return CSharpCompilation.Create( - CompilationAssemblyName, + assemblyName, syntaxTrees, references, new(OutputKind.DynamicallyLinkedLibrary)); diff --git a/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Support/XamlViewCorpus.cs b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Support/XamlViewCorpus.cs new file mode 100644 index 00000000..137ea2ad --- /dev/null +++ b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/Support/XamlViewCorpus.cs @@ -0,0 +1,323 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; +using ReactiveUI.Binding.SourceGenerators; + +namespace ReactiveUI.Binding.Generator.Benchmarks.Support; + +/// Builds Avalonia-style views that name their controls in XAML and bind them all in one activation lambda. +/// +/// A named control is a field Avalonia's own generator writes, and each view model has a [Reactive] field. Either +/// one makes the binding generator read the call sites from its private copy of the compilation. With the XAML pages +/// left out, the views declare their controls as fields, as a WPF build does, and the call sites are read from the +/// consumer's compilation. +/// +internal static class XamlViewCorpus +{ + /// The assembly name of the consumer compilation. + private const string AssemblyName = "XamlViews"; + + /// The source files each view adds: its view model and its code-behind. + private const int FilesPerView = 2; + + /// The directive every corpus file starts with. + private const string NullableDirective = "#nullable enable"; + + /// The controls, the activation stub and the attribute ReactiveUI.SourceGenerators would supply. + private const string Support = """ + #nullable enable + using System; + using System.Collections.Generic; + using System.ComponentModel; + using System.Windows.Input; + + namespace ReactiveUI.SourceGenerators + { + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] + public sealed class ReactiveAttribute : Attribute { } + } + + namespace Bench.Controls + { + public abstract class Control : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + protected void Raise(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + } + + public class TextBox : Control { private string? _text; public string? Text { get => _text; set { _text = value; Raise(nameof(Text)); } } } + public class TextBlock : Control { private string? _text; public string? Text { get => _text; set { _text = value; Raise(nameof(Text)); } } } + public class Button : Control { public ICommand? Command { get; set; } public event EventHandler? Click; public void PerformClick() => Click?.Invoke(this, EventArgs.Empty); } + } + + namespace Bench + { + public sealed class Disposables : IDisposable + { + private readonly List _items = new List(); + public void Add(IDisposable item) => _items.Add(item); + public void Dispose() { foreach (var item in _items) { item.Dispose(); } } + } + + public static class ActivationExtensions + { + public static void WhenActivated(this object view, Action block) => block(new Disposables()); + public static T DisposeWith(this T item, Disposables disposables) where T : IDisposable { disposables.Add(item); return item; } + } + } + """; + + /// The control each binding targets, in rotation: a text box bound both ways, a text block, a button. + private static readonly string[] Controls = ["TextBox", "TextBlock", "Button"]; + + /// The binding each control gets, in the same rotation. + private static readonly string[] Calls = ["Bind", "OneWayBind", "BindCommand"]; + + /// The control member each binding writes, in the same rotation; a command binds the control itself. + private static readonly string[] Targets = [".Text", ".Text", string.Empty]; + + /// The view model property type each binding reads, in the same rotation. + private static readonly string[] PropertyTypes = ["string?", "string?", "ICommand?"]; + + /// Builds the consumer compilation. + /// The number of views. + /// The number of controls each view names and binds. + /// Whether the controls come from XAML pages, which is the path that reads a copy of the compilation. + /// The XAML pages, empty when is false. + /// The compilation. + internal static CSharpCompilation Build(int views, int bindings, bool xaml, out ImmutableArray pages) + { + var parseOptions = GeneratorHarness.ParseOptions(false); + var trees = new List((views * FilesPerView) + 1) { CSharpSyntaxTree.ParseText(Support, parseOptions, "Support.cs") }; + var pageBuilder = ImmutableArray.CreateBuilder(xaml ? views : 0); + for (var view = 0; view < views; view++) + { + trees.Add(CSharpSyntaxTree.ParseText(ViewModel(view, bindings, xaml), parseOptions, $"ViewModel{view}.cs")); + trees.Add(CSharpSyntaxTree.ParseText(CodeBehind(view, bindings, xaml), parseOptions, $"View{view}.axaml.cs")); + if (xaml) + { + pageBuilder.Add(new InMemoryText($"View{view}.axaml", Page(view, bindings))); + } + } + + pages = pageBuilder.MoveToImmutable(); + var compilation = GeneratorHarness.BuildCompilation(AssemblyName, [.. trees]); + return compilation.AddReferences(RuntimeDependencies(compilation)); + } + + /// Writes the members Avalonia's and ReactiveUI.SourceGenerators' generators would add. + /// The number of views. + /// The number of controls each view names. + /// The source, which lets the generated output be compiled as a full build would. + internal static SyntaxTree OtherGeneratorsOutput(int views, int bindings) + { + var lines = new List { NullableDirective }; + for (var view = 0; view < views; view++) + { + lines.Add($"namespace Bench.ViewModels {{ public partial class ViewModel{view} {{ public string Title {{ get; set; }} = string.Empty; }} }}"); + lines.Add($"namespace Bench.Views {{ public partial class View{view} {{"); + AddFields(lines, bindings); + lines.Add("} }"); + } + + return CSharpSyntaxTree.ParseText(string.Join("\n", lines), GeneratorHarness.ParseOptions(false), "OtherGenerators.g.cs"); + } + + /// Creates a cold driver that sees the XAML pages as Avalonia marks them. + /// The XAML pages. + /// The driver. + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + internal static GeneratorDriver CreateDriver(ImmutableArray pages) => + CSharpGeneratorDriver.Create( + [new BindingGenerator().AsSourceGenerator()], + pages, + GeneratorHarness.ParseOptions(false), + new PageOptionsProvider()); + + /// Lists the runtime library's dependencies the compilation does not reference yet. + /// The compilation. + /// The references to add. + /// + /// The generated code names types from Splat, ReactiveUI.Primitives and ReactiveUI.Disposables, so the output can + /// only be checked to compile with all of their assemblies referenced. + /// + private static List RuntimeDependencies(Compilation compilation) + { + var present = new HashSet(StringComparer.Ordinal); + foreach (var reference in compilation.References) + { + _ = present.Add(Path.GetFileName(reference.Display ?? string.Empty)); + } + + var extra = new List(); + foreach (var path in Directory.GetFiles(AppContext.BaseDirectory, "*.dll")) + { + var name = Path.GetFileName(path); + if (IsRuntimeDependency(name) && present.Add(name)) + { + extra.Add(MetadataReference.CreateFromFile(path)); + } + } + + return extra; + } + + /// Determines whether an assembly file belongs to one of the runtime library's dependencies. + /// The file name. + /// for a Splat, ReactiveUI.Primitives or ReactiveUI.Disposables assembly. + private static bool IsRuntimeDependency(string name) => + name.StartsWith("Splat", StringComparison.Ordinal) + || name.StartsWith("ReactiveUI.Primitives", StringComparison.Ordinal) + || name.StartsWith("ReactiveUI.Disposables", StringComparison.Ordinal); + + /// Writes a view model with a property per control, and a [Reactive] field when XAML is on. + /// The view's index. + /// The number of controls. + /// Whether the view model carries a [Reactive] field. + /// The source. + private static string ViewModel(int view, int bindings, bool xaml) + { + var lines = new List + { + NullableDirective, + "using System.ComponentModel;", + "using System.Windows.Input;", + "namespace Bench.ViewModels {", + $"public partial class ViewModel{view} : INotifyPropertyChanged {{", + "public event PropertyChangedEventHandler? PropertyChanged;", + }; + if (xaml) + { + lines.Add("[ReactiveUI.SourceGenerators.Reactive] private string _title = string.Empty;"); + } + + for (var i = 0; i < bindings; i++) + { + lines.Add($"public {PropertyTypes[i % PropertyTypes.Length]} P{i} {{ get; set; }}"); + } + + lines.Add("} }"); + return string.Join("\n", lines); + } + + /// Writes a view's code-behind, binding every control in one activation lambda. + /// The view's index. + /// The number of controls. + /// Whether the controls come from the page; otherwise the class declares them. + /// The source. + private static string CodeBehind(int view, int bindings, bool xaml) + { + var lines = new List + { + NullableDirective, + "using ReactiveUI.Binding;", + "using Bench.ViewModels;", + "namespace Bench.Views {", + $"public partial class View{view} : IViewFor {{", + $"public ViewModel{view}? ViewModel {{ get; set; }}", + $"object? IViewFor.ViewModel {{ get => ViewModel; set => ViewModel = (ViewModel{view}?)value; }}", + }; + if (!xaml) + { + AddFields(lines, bindings); + } + + lines.Add($"public View{view}() {{"); + lines.Add("this.WhenActivated(disposables => {"); + for (var i = 0; i < bindings; i++) + { + lines.Add($"this.{Calls[i % Calls.Length]}(ViewModel, vm => vm.P{i}, v => v.C{i}{Targets[i % Targets.Length]}).DisposeWith(disposables);"); + } + + lines.Add("}); } } }"); + return string.Join("\n", lines); + } + + /// Adds a field per control, as Avalonia's generator or a WPF build declares them. + /// The lines to add to. + /// The number of controls. + private static void AddFields(List lines, int bindings) + { + for (var i = 0; i < bindings; i++) + { + var type = Controls[i % Controls.Length]; + lines.Add($"internal global::Bench.Controls.{type} C{i} = new global::Bench.Controls.{type}();"); + } + } + + /// Writes a view's XAML page, naming every control. + /// The view's index. + /// The number of controls. + /// The page. + private static string Page(int view, int bindings) + { + var lines = new List + { + $"", + "", + }; + for (var i = 0; i < bindings; i++) + { + lines.Add($""); + } + + lines.Add(""); + lines.Add(""); + return string.Join("\n", lines); + } + + /// An additional file held in memory. + /// The file's path. + /// The file's text. + private sealed class InMemoryText(string path, string content) : AdditionalText + { + /// The file's text. + private readonly SourceText _text = SourceText.From(content, Encoding.UTF8); + + /// + public override string Path { get; } = path; + + /// + public override SourceText GetText(CancellationToken cancellationToken = default) => _text; + } + + /// Marks every additional file as an Avalonia XAML page. + private sealed class PageOptionsProvider : AnalyzerConfigOptionsProvider + { + /// The options every file carries: the item metadata Avalonia marks its XAML files with. + private static readonly AnalyzerConfigOptions PageOptions = new Options("build_metadata.AdditionalFiles.SourceItemGroup", "AvaloniaXaml"); + + /// The build's options, which set nothing. + private static readonly AnalyzerConfigOptions NoOptions = new Options(null, null); + + /// + public override AnalyzerConfigOptions GlobalOptions => NoOptions; + + /// + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => NoOptions; + + /// + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => PageOptions; + } + + /// Options holding at most one key. + /// The key, or null for none. + /// The key's value. + private sealed class Options(string? optionKey, string? optionValue) : AnalyzerConfigOptions + { + /// + public override bool TryGetValue(string key, [NotNullWhen(true)] out string? value) + { + value = string.Equals(optionKey, key, StringComparison.Ordinal) ? optionValue : null; + return value is not null; + } + } +} diff --git a/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/XamlViewGenerationBenchmarks.cs b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/XamlViewGenerationBenchmarks.cs new file mode 100644 index 00000000..88c152f1 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Binding.Generator.Benchmarks/XamlViewGenerationBenchmarks.cs @@ -0,0 +1,125 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.Immutable; +using BenchmarkDotNet.Attributes; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using ReactiveUI.Binding.Generator.Benchmarks.Support; + +namespace ReactiveUI.Binding.Generator.Benchmarks; + +/// Measures a generation pass over Avalonia-style views that bind every named control in one lambda. +/// +/// With on, the controls are named in XAML pages and a view model has a [Reactive] field, so +/// the call sites are read from the generator's private copy of the compilation. With it off, the views declare their +/// controls and the call sites are read from the consumer's compilation. Growing shows how the +/// cost of one view grows with the call sites in its lambda. +/// +public class XamlViewGenerationBenchmarks +{ + /// The number of views in the corpus. + private const int Views = 4; + + /// The consumer compilation. + private Compilation _compilation = null!; + + /// The consumer compilation after a one-line edit to one view. + private Compilation _edited = null!; + + /// A driver that has already run over the unedited compilation. + private GeneratorDriver _primed = null!; + + /// The XAML pages the driver sees. + private ImmutableArray _pages; + + /// The validated size of the generated output. + private int _characters; + + /// Gets or sets the number of controls each view names and binds. + [Params(10, 50, 100)] + public int Bindings { get; set; } + + /// Gets or sets a value indicating whether the controls come from XAML pages. + [Params(false, true)] + public bool Xaml { get; set; } + + /// Builds the corpus and checks the generated output compiles and covers every call site. + /// The output does not compile. + [GlobalSetup] + public void Setup() + { + _compilation = XamlViewCorpus.Build(Views, Bindings, Xaml, out _pages); + var driver = XamlViewCorpus.CreateDriver(_pages).RunGeneratorsAndUpdateCompilation(_compilation, out var output, out _); + + // The other generators do not run here, so their members are added the way a full build would add them. + var full = Xaml ? output.AddSyntaxTrees(XamlViewCorpus.OtherGeneratorsOutput(Views, Bindings)) : output; + foreach (var diagnostic in full.GetDiagnostics()) + { + if (diagnostic.Severity == DiagnosticSeverity.Error) + { + throw new InvalidOperationException($"The generated output does not compile: {diagnostic}"); + } + } + + _characters = Count(driver.GetRunResult()); + + // A one-line edit to one view, as a developer makes between two builds. + foreach (var tree in _compilation.SyntaxTrees) + { + if (!tree.FilePath.EndsWith(".axaml.cs", StringComparison.Ordinal)) + { + continue; + } + + var text = tree.GetText(); + _edited = _compilation.ReplaceSyntaxTree(tree, tree.WithChangedText(text.WithChanges(new TextChange(new(text.Length, 0), "\n// edited")))); + break; + } + } + + /// Primes a driver with a run over the unedited views, so the next run is incremental. + [IterationSetup(Target = nameof(Edit))] + public void PrimeDriver() => _primed = XamlViewCorpus.CreateDriver(_pages).RunGenerators(_compilation); + + /// Runs the generation that follows a one-line edit to one view, reusing the previous run's caches. + /// The number of generated characters. + /// The pass generated something other than the validated output. + [Benchmark] + public int Edit() + { + // The primed driver's caches serve every step whose input the edit did not change. + var result = _primed.RunGenerators(_edited).GetRunResult(); + var characters = Count(result); + return characters == _characters + ? characters + : throw new InvalidOperationException("An edit pass changed the validated output."); + } + + /// Runs a whole cold generation over the views. + /// The number of generated characters. + /// The pass generated something other than the validated output. + [Benchmark] + public int Generate() + { + var characters = Count(XamlViewCorpus.CreateDriver(_pages).RunGenerators(_compilation).GetRunResult()); + return characters == _characters + ? characters + : throw new InvalidOperationException("A generation pass changed the validated output."); + } + + /// Counts the generated characters. + /// The run result. + /// The number of characters. + private static int Count(GeneratorDriverRunResult result) + { + var characters = 0; + foreach (var generated in result.Results[0].GeneratedSources) + { + characters += generated.SourceText.Length; + } + + return characters; + } +} diff --git a/src/benchmarks/issue-166-results.md b/src/benchmarks/issue-166-results.md new file mode 100644 index 00000000..34842fdc --- /dev/null +++ b/src/benchmarks/issue-166-results.md @@ -0,0 +1,95 @@ +# Issue 166: shared semantic models + +The fix is on `perf/share-semantic-model-per-tree`, based on `b0c70a0`. +The comparison worktree is `/tmp/rxb-before`. + +The generator shares one semantic model per source file in its compilation copy. +A `ConditionalWeakTable` ties the cache to that compilation's lifetime. +A `ConcurrentDictionary` lets parallel readers share the same model. +Generated output and public APIs stay the same. + +## Real application + +The [issue](https://github.com/reactiveui/ReactiveUI.Binding.SourceGenerators/issues/166) names +v2rayN commit `09a84ded3dfbf762adddde9375603c0a68d546fc`. +The local reproduction is `/tmp/v2rayN`. +Each measurement follows a comment edit in `MainWindow.axaml.cs`. +The compiler server is warm. Each result is the median of three builds. + +| Measurement | Baseline | Fixed | +| --- | ---: | ---: | +| Generator time | 10.968 s | 0.441 s | +| Process wall time | 17.445 s | 5.806 s | + +The local packages are `9.0.1-base166` and `9.0.1-perf166`. +They use the same repository base. +Windows example checks ran during these builds, so absolute timings include shared-machine noise. +The saved earlier runs also show the gain. + +All 60 saved generated files match byte for byte. +Binding's eight files total 6,866,986 bytes and 90,331 lines in each build. +This fix removes repeated semantic binding. It does not reduce emitted code size. + +## Synthetic benchmark + +Four views bind 10, 50 or 100 controls each in one activation lambda. +The XAML case also uses a `[Reactive]` field. +The control case declares fields directly. +Setup checks that generated code compiles. +Both measured methods reject output sizes that differ from setup. + +Fresh runs use BenchmarkDotNet on .NET 10 and 11. +They pin processes to physical cores 0 through 6. +Each case has three warmups and eight measured iterations. +Profilers are disabled for timing. GC uses concurrent workstation mode. +The CPU governor is `powersave`. Higher process priority is unavailable. +Small timing differences need caution on this shared machine. + +Cold generation with XAML, mean ± BenchmarkDotNet error: + +| Runtime | Bindings per view | Baseline | Fixed | Baseline allocation | Fixed allocation | +| --- | ---: | ---: | ---: | ---: | ---: | +| .NET 10 | 10 | 128.35 ± 4.797 ms | 20.08 ± 0.354 ms | 33.38 MB | 5.22 MB | +| .NET 10 | 50 | 2,764.30 ± 74.607 ms | 101.85 ± 6.262 ms | 826.55 MB | 28.77 MB | +| .NET 10 | 100 | 11,950.64 ± 240.571 ms | 228.37 ± 18.160 ms | 3,703.48 MB | 61.20 MB | +| .NET 11 | 10 | 121.11 ± 4.091 ms | 20.80 ± 1.132 ms | 33.36 MB | 5.22 MB | +| .NET 11 | 50 | 3,065.43 ± 558.707 ms | 96.40 ± 4.076 ms | 826.23 MB | 28.76 MB | +| .NET 11 | 100 | 12,793.31 ± 494.441 ms | 203.66 ± 8.244 ms | 3,702.24 MB | 61.18 MB | + +Direct-compilation allocation differs by at most 0.02 MB in these runs. +Its timing differences are noisy. This change targets the compilation-copy path. + +The saved comment-edit measurements on .NET 10 also show the gain. +At 100 bindings per view, edit generation falls from 11,192 ± 231 ms to 209 ± 78 ms. +Allocations fall from 3,684 MB to 42 MB. + +## Allocation profile + +The .NET 10 EventPipe traces include CPU samples and verbose GC events. +In the baseline, binding extraction contains 67.6% of samples with a generator frame. +Command extraction contains 31.0%. +Compilation-copy construction contains about 0.1%. XAML reading and resolution each contain less than 0.1%. +These are inclusive sample shares, not wall-clock percentages. + +Most baseline allocation samples are Roslyn binder and symbol objects beneath those extractors. +The fixed trace has 2,393 allocation ticks attributed to generator frames. +The trace cannot resolve the cache's small setup allocations. +The benchmark allocation columns measure total bytes per generation. + +All 12 fixed cold-generation and comment-edit cases were also exercised with tracing enabled. + +## Verification + +- Release solution-filter build: no warnings or errors. +- Full Linux suite: 13,126 passed; 138 skipped; no failures. +- `CallSiteContext.cs`: 100% line and branch coverage across both generator builds and runtimes. +- Changed-file formatting and analyzer check passed. +- All Linux documentation and platform examples passed. +- Native AOT publish and execution passed. +- Windows property, platform and WPF/WinForms threading examples passed. + +The Windows mirror lacks git metadata. Version stamping was skipped for its example runs. +Those builds emit source-link metadata warnings. +No analyzer suppression was added. + +Logs and measurement artifacts are under `/tmp/issue166-*`. diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/CallSiteModelSharingTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/CallSiteModelSharingTests.cs new file mode 100644 index 00000000..56d60c26 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/CallSiteModelSharingTests.cs @@ -0,0 +1,285 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; +using ReactiveUI.Binding.SourceGenerators.Helpers; +using ReactiveUI.Binding.SourceGenerators.Tests.Helpers; + +namespace ReactiveUI.Binding.SourceGenerators.Tests; + +/// +/// Covers reading call sites from the compilation that declares the members other generators write. Every call site in +/// one file shares one semantic model, so a file with many call sites in one method binds that method once. +/// +public class CallSiteModelSharingTests +{ + /// The number of controls the view binds. + private const int Controls = 9; + + /// The number of reads each processor makes at once. + private const int ReadsPerProcessor = 4; + + /// The control types the view rotates through: two text boxes, then a button. + private const int ControlKinds = 3; + + /// A file with one class and no call sites. + private const string OneClass = "class A { }"; + + /// The path of the view's code-behind, which both compilations share. + private const string CodeBehindPath = "/App/LoginView.axaml.cs"; + + /// The controls and the view model. + private const string Support = """ + #nullable enable + using System.ComponentModel; + using System.Windows.Input; + + namespace App + { + public class TextBox : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + public string? Text { get; set; } + } + + public class Button + { + public event System.EventHandler? Click; + public ICommand? Command { get; set; } + } + + public class LoginViewModel : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + public string? P0 { get; set; } + public string? P1 { get; set; } + public ICommand? P2 { get; set; } + public string? P3 { get; set; } + public string? P4 { get; set; } + public ICommand? P5 { get; set; } + public string? P6 { get; set; } + public string? P7 { get; set; } + public ICommand? P8 { get; set; } + } + + public static class Activation + { + public static void WhenActivated(this object view, System.Action> block) => + block(new System.Collections.Generic.List()); + } + } + """; + + /// A view that binds every control in one activation lambda. + private const string CodeBehind = """ + #nullable enable + using ReactiveUI.Binding; + + namespace App + { + public partial class LoginView : IViewFor + { + public LoginView() + { + this.WhenActivated(d => + { + d.Add(this.Bind(ViewModel, vm => vm.P0, v => v.C0.Text)); + d.Add(this.OneWayBind(ViewModel, vm => vm.P1, v => v.C1.Text)); + d.Add(this.BindCommand(ViewModel, vm => vm.P2, v => v.C2)); + d.Add(this.Bind(ViewModel, vm => vm.P3, v => v.C3.Text)); + d.Add(this.OneWayBind(ViewModel, vm => vm.P4, v => v.C4.Text)); + d.Add(this.BindCommand(ViewModel, vm => vm.P5, v => v.C5)); + d.Add(this.Bind(ViewModel, vm => vm.P6, v => v.C6.Text)); + d.Add(this.OneWayBind(ViewModel, vm => vm.P7, v => v.C7.Text)); + d.Add(this.BindCommand(ViewModel, vm => vm.P8, v => v.C8)); + }); + } + + public LoginViewModel? ViewModel { get; set; } + + object? IViewFor.ViewModel + { + get => ViewModel; + set => ViewModel = (LoginViewModel?)value; + } + } + } + """; + + /// Asking twice for a file's model gives the same model, so its bound members are reused. + /// A task representing the asynchronous test operation. + [Test] + public async Task SameFile_SharesOneModel() + { + var compilation = TestHelper.CreateCompilation(OneClass, LanguageVersion.CSharp10); + var tree = compilation.SyntaxTrees.First(); + + var first = CallSiteContext.ModelFor(compilation, tree); + var second = CallSiteContext.ModelFor(compilation, tree); + + await Assert.That(second).IsSameReferenceAs(first); + await Assert.That(first.SyntaxTree).IsSameReferenceAs(tree); + await Assert.That(first.Compilation).IsSameReferenceAs(compilation); + } + + /// Each file, and each compilation, gets a model of its own. + /// A task representing the asynchronous test operation. + [Test] + public async Task OtherFileOrCompilation_GetsItsOwnModel() + { + var compilation = TestHelper.CreateCompilation(OneClass, LanguageVersion.CSharp10); + var tree = compilation.SyntaxTrees.First(); + var other = CSharpSyntaxTree.ParseText("class B { }", TestHelper.ParseOptionsFor(LanguageVersion.CSharp10)); + var withOther = compilation.AddSyntaxTrees(other); + + var model = CallSiteContext.ModelFor(withOther, tree); + + await Assert.That(CallSiteContext.ModelFor(withOther, other)).IsNotSameReferenceAs(model); + await Assert.That(CallSiteContext.ModelFor(compilation, tree)).IsNotSameReferenceAs(model); + } + + /// Call sites read in parallel, as the generator's transforms are, all receive the one model. + /// A task representing the asynchronous test operation. + [Test] + public async Task ParallelReads_ShareOneModel() + { + var compilation = TestHelper.CreateCompilation(OneClass, LanguageVersion.CSharp10); + var tree = compilation.SyntaxTrees.First(); + var models = new ConcurrentBag(); + + _ = Parallel.For(0, Environment.ProcessorCount * ReadsPerProcessor, _ => models.Add(CallSiteContext.ModelFor(compilation, tree))); + + await Assert.That(models.Distinct().Count()).IsEqualTo(1); + } + + /// + /// A view whose controls an Avalonia page names is read from the compilation copy, and generates the same code as + /// the same view with its controls declared in source. + /// + /// A task representing the asynchronous test operation. + [Test] + public async Task ControlsNamedInXaml_GenerateTheSameCodeAsDeclaredControls() + { + var parseOptions = TestHelper.ParseOptionsFor(LanguageVersion.CSharp10); + var baseline = TestHelper.CreateCompilation(Support, LanguageVersion.CSharp10) + .AddSyntaxTrees(CSharpSyntaxTree.ParseText(CodeBehind, parseOptions, CodeBehindPath)); + + var declared = Run(baseline.AddSyntaxTrees(CSharpSyntaxTree.ParseText(Fields(), parseOptions, "/App/LoginView.Fields.cs")), parseOptions, []); + var named = Run(baseline, parseOptions, [new Page(Xaml())]); + + await Assert.That(declared.CompilationErrors).IsEmpty(); + var namedOutput = named.OutputCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(Fields(), parseOptions, "/App/LoginView.Fields.cs")); + await Assert.That(namedOutput.GetDiagnostics().Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)).IsEmpty(); + await Assert.That(named.GeneratorDiagnostics).IsEmpty(); + await Assert.That(declared.GeneratedSources.Count).IsGreaterThan(1); + await Assert.That(named.GeneratedSources.Keys).IsEquivalentTo(declared.GeneratedSources.Keys); + foreach (var (hint, text) in declared.GeneratedSources) + { + await Assert.That(named.GeneratedSources[hint]).IsEqualTo(text); + } + + await Assert.That(declared.GeneratedSources["BindDispatch.g.cs"]).Contains("C6"); + await Assert.That(declared.GeneratedSources["OneWayBindDispatch.g.cs"]).Contains("C7"); + await Assert.That(declared.GeneratedSources["BindCommandDispatch.g.cs"]).Contains("C8"); + } + + /// Runs the generator with the given XAML pages, which Avalonia marks as its own. + /// The compilation. + /// The parse options. + /// The pages. + /// The result. + private static GeneratorTestResult Run(Compilation compilation, CSharpParseOptions parseOptions, AdditionalText[] pages) + { + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new BindingGenerator().AsSourceGenerator()], + pages, + parseOptions, + new AvaloniaOptions()); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var output, out var diagnostics); + return new(driver, output, diagnostics); + } + + /// Declares the controls as fields, as Avalonia's own generator writes them. + /// The source. + private static string Fields() + { + var lines = new List { "namespace App {", "public partial class LoginView {" }; + for (var i = 0; i < Controls; i++) + { + lines.Add($"internal global::App.{ControlType(i)} C{i} = new global::App.{ControlType(i)}();"); + } + + lines.Add("} }"); + return string.Join("\n", lines); + } + + /// Writes the page that names the controls. + /// The page. + private static string Xaml() + { + var lines = new List + { + "", + "", + }; + for (var i = 0; i < Controls; i++) + { + lines.Add($""); + } + + lines.Add(""); + lines.Add(""); + return string.Join("\n", lines); + } + + /// Picks a control's type: every third control is a button. + /// The control's index. + /// The type name. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static string ControlType(int index) => (index % ControlKinds) is ControlKinds - 1 ? "Button" : "TextBox"; + + /// A XAML page held in memory. + /// The page's text. + private sealed class Page(string text) : AdditionalText + { + /// + public override string Path => "/App/LoginView.axaml"; + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override SourceText GetText(CancellationToken cancellationToken = default) => SourceText.From(text); + } + + /// Marks every additional file as an Avalonia XAML page. + private sealed class AvaloniaOptions : AnalyzerConfigOptionsProvider + { + /// + public override AnalyzerConfigOptions GlobalOptions { get; } = new Value(null); + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => GlobalOptions; + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => new Value("AvaloniaXaml"); + } + + /// Options that carry Avalonia's item group, or nothing. + /// The item group, or null for none. + private sealed class Value(string? itemGroup) : AnalyzerConfigOptions + { + /// + public override bool TryGetValue(string key, out string value) + { + value = itemGroup ?? string.Empty; + return itemGroup is not null && string.Equals(key, "build_metadata.AdditionalFiles.SourceItemGroup", StringComparison.Ordinal); + } + } +}