From 98ce0cf123fb86c44013183c51bd50d46a62c919 Mon Sep 17 00:00:00 2001 From: Emil Kalchev Date: Wed, 14 Oct 2026 00:31:33 +0300 Subject: [PATCH 1/2] Look up each name of a generic factory once per generation, and parse each rewritten type once A generic factory is rewritten for every closed type it is resolved with. For each rewrite, FactoryTypeRewriter asked the semantic model for the symbol of every name in the factory, and parsed a new type syntax for every name it rewrites. On a composition with many Func, Owned and Func injections, GetSymbolInfo inside the factory lambdas was the largest single cost of generation. The symbol depends only on the semantic model and the node, and the type syntax only on the node and the rewritten type name, so both are kept in a PerResolve cache that lives for one generation. Marker and closed-type decisions are not cached. Measured on a WinUI composition of about 1 MB of generated code (interleaved runs): the symbol cache took the generator from 154-167 s to 91-113 s; the type syntax cache, on top of the next commit, from 64-76 s to 51-62 s. The generated code is unchanged. --- src/Pure.DI.Core/Core/FactoryRewriteCache.cs | 77 +++++++++++++ src/Pure.DI.Core/Core/FactoryTypeRewriter.cs | 9 +- src/Pure.DI.Core/Core/IFactoryRewriteCache.cs | 8 ++ src/Pure.DI.Core/Generator.cs | 2 +- .../Pure.DI.Tests/FactoryRewriteCacheTests.cs | 103 ++++++++++++++++++ 5 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 src/Pure.DI.Core/Core/FactoryRewriteCache.cs create mode 100644 src/Pure.DI.Core/Core/IFactoryRewriteCache.cs create mode 100644 tests/Pure.DI.Tests/FactoryRewriteCacheTests.cs diff --git a/src/Pure.DI.Core/Core/FactoryRewriteCache.cs b/src/Pure.DI.Core/Core/FactoryRewriteCache.cs new file mode 100644 index 000000000..0e93f8236 --- /dev/null +++ b/src/Pure.DI.Core/Core/FactoryRewriteCache.cs @@ -0,0 +1,77 @@ +// ReSharper disable ClassNeverInstantiated.Global + +namespace Pure.DI.Core; + +using System.Runtime.CompilerServices; + +// A generic factory is rewritten for every closed type it is resolved with, over the same syntax. +// Per generation, this keeps the type each name of a factory binds to and the syntax it is rewritten to. +// Its keys hold syntax nodes and semantic models, so it must not outlive the generation. +sealed class FactoryRewriteCache : IFactoryRewriteCache +{ + private readonly Dictionary _typeSymbols = new(); + private readonly Dictionary _typeSyntaxes = new(); + + public ITypeSymbol? GetTypeSymbol(SemanticModel semanticModel, SyntaxNode node) + { + var key = new SymbolKey(semanticModel, node); + if (!_typeSymbols.TryGetValue(key, out var type)) + { + type = semanticModel.GetSymbolInfo(node).Symbol as ITypeSymbol; + _typeSymbols.Add(key, type); + } + + return type; + } + + // Syntax nodes are immutable, so one node serves every rewrite of the same name to the same type. + public TypeSyntax GetTypeSyntax(SyntaxNode node, string typeName) + { + var key = new TypeSyntaxKey(node, typeName); + if (!_typeSyntaxes.TryGetValue(key, out var typeSyntax)) + { + typeSyntax = SyntaxFactory.ParseTypeName(typeName) + .WithLeadingTrivia(node.GetLeadingTrivia()) + .WithTrailingTrivia(node.GetTrailingTrivia()); + _typeSyntaxes.Add(key, typeSyntax); + } + + return typeSyntax; + } + + private readonly struct SymbolKey(SemanticModel semanticModel, SyntaxNode node) : IEquatable + { + private readonly SemanticModel _semanticModel = semanticModel; + private readonly SyntaxNode _node = node; + + public bool Equals(SymbolKey other) => ReferenceEquals(_semanticModel, other._semanticModel) && ReferenceEquals(_node, other._node); + + public override bool Equals(object? obj) => obj is SymbolKey other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + return RuntimeHelpers.GetHashCode(_semanticModel) * 397 ^ RuntimeHelpers.GetHashCode(_node); + } + } + } + + private readonly struct TypeSyntaxKey(SyntaxNode node, string typeName) : IEquatable + { + private readonly SyntaxNode _node = node; + private readonly string _typeName = typeName; + + public bool Equals(TypeSyntaxKey other) => ReferenceEquals(_node, other._node) && _typeName == other._typeName; + + public override bool Equals(object? obj) => obj is TypeSyntaxKey other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + return RuntimeHelpers.GetHashCode(_node) * 397 ^ _typeName.GetHashCode(); + } + } + } +} diff --git a/src/Pure.DI.Core/Core/FactoryTypeRewriter.cs b/src/Pure.DI.Core/Core/FactoryTypeRewriter.cs index 6c449da50..bbe87d025 100644 --- a/src/Pure.DI.Core/Core/FactoryTypeRewriter.cs +++ b/src/Pure.DI.Core/Core/FactoryTypeRewriter.cs @@ -6,7 +6,8 @@ namespace Pure.DI.Core; sealed class FactoryTypeRewriter( IMarker marker, ITypeResolver typeResolver, - ITypes types) + ITypes types, + IFactoryRewriteCache cache) : CSharpSyntaxRewriter, IFastBuilder, MdFactory> { private RewriterContext _context; @@ -53,9 +54,7 @@ public MdFactory Build(in RewriterContext context) private SyntaxNode? TryCreateTypeSyntax(SyntaxNode node) => TryGetNewTypeName(node, !IsObjectCreationType(node), out var newTypeName) - ? SyntaxFactory.ParseTypeName(newTypeName) - .WithLeadingTrivia(node.GetLeadingTrivia()) - .WithTrailingTrivia(node.GetTrailingTrivia()) + ? cache.GetTypeSyntax(node, newTypeName) : null; private bool TryGetNewTypeName(SyntaxNode? node, bool includeNullableAnnotation, [NotNullWhen(true)] out string? newTypeName) @@ -69,7 +68,7 @@ private bool TryGetNewTypeName(SyntaxNode? node, bool includeNullableAnnotation, var semanticModel = _context.State.SemanticModel; if (node.SyntaxTree == semanticModel.SyntaxTree) { - if (semanticModel.GetSymbolInfo(node).Symbol is ITypeSymbol type) + if (cache.GetTypeSymbol(semanticModel, node) is {} type) { return TryGetNewTypeName(type, true, includeNullableAnnotation, out newTypeName); } diff --git a/src/Pure.DI.Core/Core/IFactoryRewriteCache.cs b/src/Pure.DI.Core/Core/IFactoryRewriteCache.cs new file mode 100644 index 000000000..d32836d9b --- /dev/null +++ b/src/Pure.DI.Core/Core/IFactoryRewriteCache.cs @@ -0,0 +1,8 @@ +namespace Pure.DI.Core; + +interface IFactoryRewriteCache +{ + ITypeSymbol? GetTypeSymbol(SemanticModel semanticModel, SyntaxNode node); + + TypeSyntax GetTypeSyntax(SyntaxNode node, string typeName); +} diff --git a/src/Pure.DI.Core/Generator.cs b/src/Pure.DI.Core/Generator.cs index 7f8f1ef0a..0945d4afc 100644 --- a/src/Pure.DI.Core/Generator.cs +++ b/src/Pure.DI.Core/Generator.cs @@ -122,7 +122,7 @@ private void Setup() => DI.Setup() // PerResolve .PerResolve, Locks, RootAccessModifierResolver, SmartTags, GenericTypeArguments, - NameProvider, OverrideIdProvider, OverridesRegistry, Accumulators, Constructors>() + NameProvider, OverrideIdProvider, OverridesRegistry, Accumulators, Constructors, FactoryRewriteCache>() // Id generators .PerResolve(UniqueTagIdGenerator) diff --git a/tests/Pure.DI.Tests/FactoryRewriteCacheTests.cs b/tests/Pure.DI.Tests/FactoryRewriteCacheTests.cs new file mode 100644 index 000000000..53e1a4a36 --- /dev/null +++ b/tests/Pure.DI.Tests/FactoryRewriteCacheTests.cs @@ -0,0 +1,103 @@ +namespace Pure.DI.Tests; + +using Core; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +public class FactoryRewriteCacheTests +{ + private const string Code = """ + class Dependency { } + + class Factory + { + object Create(int count) + { + Dependency value = new Dependency(); + return count > 0 ? value : null; + } + } + """; + + [Fact] + public void ShouldProvideTypeOfName() + { + // Given + var (semanticModel, root) = Compile(); + var name = FindName(root, "Dependency"); + var cache = new FactoryRewriteCache(); + + // When + var type = cache.GetTypeSymbol(semanticModel, name); + + // Then + type.ShouldNotBeNull(); + type.Name.ShouldBe("Dependency"); + cache.GetTypeSymbol(semanticModel, name).ShouldBeSameAs(type); + } + + [Fact] + public void ShouldProvideNoTypeForNameOfValue() + { + // Given + var (semanticModel, root) = Compile(); + var name = FindName(root, "count"); + var cache = new FactoryRewriteCache(); + + // When + var type = cache.GetTypeSymbol(semanticModel, name); + + // Then + type.ShouldBeNull(); + cache.GetTypeSymbol(semanticModel, name).ShouldBeNull(); + } + + [Fact] + public void ShouldReuseTypeSyntaxForSameNameAndType() + { + // Given + var (_, root) = Compile(); + var name = FindName(root, "Dependency"); + var cache = new FactoryRewriteCache(); + + // When + var typeSyntax = cache.GetTypeSyntax(name, "global::Other"); + + // Then + typeSyntax.ToFullString().ShouldBe(name.ToFullString().Replace("Dependency", "global::Other")); + cache.GetTypeSyntax(name, "global::Other").ShouldBeSameAs(typeSyntax); + } + + [Fact] + public void ShouldBuildTypeSyntaxForEachType() + { + // Given + var (_, root) = Compile(); + var name = FindName(root, "Dependency"); + var cache = new FactoryRewriteCache(); + + // When + var first = cache.GetTypeSyntax(name, "global::First"); + var second = cache.GetTypeSyntax(name, "global::Second"); + + // Then + first.ToString().ShouldBe("global::First"); + second.ToString().ShouldBe("global::Second"); + } + + private static (SemanticModel SemanticModel, SyntaxNode Root) Compile() + { + var tree = CSharpSyntaxTree.ParseText(Code); + var compilation = CSharpCompilation.Create( + nameof(FactoryRewriteCacheTests), + [tree], + [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)]); + + return (compilation.GetSemanticModel(tree), tree.GetRoot()); + } + + // The first occurrence: for "Dependency", the type of the local variable. + private static IdentifierNameSyntax FindName(SyntaxNode root, string name) => + root.DescendantNodes().OfType().First(i => i.Identifier.Text == name); +} From ab3154eb6916ccff0b89bda88628a6d1f80cc4fe Mon Sep 17 00:00:00 2001 From: Emil Kalchev Date: Wed, 14 Oct 2026 00:31:33 +0300 Subject: [PATCH 2/2] Order only the bindings that can serve a generic injection, in one reused list For a generic injection that is not in the map, the graph builder ordered a copy of the whole injection map by binding id and then skipped the entries whose key is not a named type, differs in being generic, or has another tag. The map grows with every resolved injection, so on large compositions each such injection copied and sorted thousands of entries into a new array. Those entries are now skipped before ordering, into one list reused for every injection. The order is unchanged: newest binding first, map order among equal ids. Measured on the same composition, with the symbol cache of the previous commit in place: 91-113 s before, 73-87 s with the filter, 64-76 s with the reused list. The generated code is unchanged. --- .../Core/DependencyGraphBuilder.cs | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/Pure.DI.Core/Core/DependencyGraphBuilder.cs b/src/Pure.DI.Core/Core/DependencyGraphBuilder.cs index ff97050ec..d22d9f351 100644 --- a/src/Pure.DI.Core/Core/DependencyGraphBuilder.cs +++ b/src/Pure.DI.Core/Core/DependencyGraphBuilder.cs @@ -80,6 +80,7 @@ public IEnumerable Build(GraphBuildContext ctx) var edgesMap = new Dictionary>(); var injectionPositionComparer = new InjectionPositionComparer(injectionComparer); var handledInjections = new Dictionary>(); + var genericCandidates = new List(); var counter = 0; while (queue.Count > 0) { @@ -207,23 +208,22 @@ public IEnumerable Build(GraphBuildContext ctx) when namedTypeSymbol.IsGenericType || marker.IsMarkerBased(setup, namedTypeSymbol): { var isDone = false; - foreach (var item in map.OrderByDescending(i => i.Value.Binding.Id)) + // Candidates are tried from the newest binding down, in map order among equal ids. + // The map is filtered before it is ordered, into a list reused for every injection. + genericCandidates.Clear(); + foreach (var candidate in map) { - if (item.Key.Type is not INamedTypeSymbol nextNamedTypeSymbol) + if (candidate.Key.Type is INamedTypeSymbol nextNamedTypeSymbol + && nextNamedTypeSymbol.IsGenericType == namedTypeSymbol.IsGenericType + && Injection.EqualTags(injection.Tag, candidate.Key.Tag)) { - continue; - } - - if (nextNamedTypeSymbol.IsGenericType != namedTypeSymbol.IsGenericType) - { - continue; - } - - if (!Injection.EqualTags(injection.Tag, item.Key.Tag)) - { - continue; + genericCandidates.Add(new GenericCandidate(genericCandidates.Count, candidate)); } + } + genericCandidates.Sort(GenericCandidate.NewestFirst); + foreach (var (_, item) in genericCandidates) + { if (!typeConstructor.TryBind(setup, item.Key.Type, injection.Type)) { continue; @@ -902,4 +902,14 @@ internal readonly struct ConstructKindKey(INamedTypeSymbol typeSymbol, ITypeSymb public override int GetHashCode() => typeSymbolComparer.GetRuntimeHashCode(TypeSymbol); } + + // The position keeps map order among equal binding ids, as List.Sort is not stable. + private readonly record struct GenericCandidate(int Position, KeyValuePair Item) + { + public static readonly Comparison NewestFirst = (x, y) => + { + var byId = y.Item.Value.Binding.Id.CompareTo(x.Item.Value.Binding.Id); + return byId != 0 ? byId : x.Position.CompareTo(y.Position); + }; + } }