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);
+ }
+ }
+}